first commit

This commit is contained in:
2026-06-24 09:48:54 +02:00
commit 41e62ddcad
33739 changed files with 4266226 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
Copyright 2021 Gabriel Santerre
Copyright 2019 Atlassian Pty Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+172
View File
@@ -0,0 +1,172 @@
<p align="center">
<img src="https://user-images.githubusercontent.com/2182637/53611918-54c1ff80-3c24-11e9-9917-66ac3cef513d.png" alt="react beautiful dnd logo" />
</p>
<h1 align="center">@hello-pangea/dnd</h1>
<div align="center">
**Beautiful** and **accessible** drag and drop for lists with [`React`](https://facebook.github.io/react/)
[![CircleCI branch](https://img.shields.io/circleci/project/github/hello-pangea/dnd/main.svg)](https://circleci.com/gh/hello-pangea/dnd/tree/main)
[![npm](https://img.shields.io/npm/v/@hello-pangea/dnd.svg)](https://www.npmjs.com/package/@hello-pangea/dnd)
[![Discord](https://img.shields.io/discord/1007763479010234398?color=blue)](https://discord.gg/zKhPpmvCEv)
[![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-blue.svg)](https://conventionalcommits.org)
[![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-blue.svg)](http://commitizen.github.io/cz-cli/)
![quote application example](https://user-images.githubusercontent.com/2182637/53614150-efbed780-3c2c-11e9-9204-a5d2e746faca.gif)
[Play with this example if you want!](https://dnd.hellopangea.com/?path=/story/examples-board--simple)
</div>
## Core characteristics
- Beautiful and [natural movement](/docs/about/animations.md) of items 💐
- [Accessible](/docs/about/accessibility.md): powerful keyboard and screen reader support ♿️
- [Extremely performant](/docs/support/media.md) 🚀
- Clean and powerful api which is simple to get started with
- Plays extremely well with standard browser interactions
- [Unopinionated styling](/docs/guides/preset-styles.md)
- No creation of additional wrapper dom nodes - flexbox and focus management friendly!
## Get started 👩‍🏫
Alex Reardon has created [a free course on `egghead.io` 🥚](https://egghead.io/courses/beautiful-and-accessible-drag-and-drop-with-react-beautiful-dnd) (using [react-beautiful-dnd](https://github.com/atlassian/react-beautiful-dnd)) to help you get started with `@hello-pangea/dnd` as quickly as possible.
[![course-logo](https://user-images.githubusercontent.com/2182637/43372837-8c72d3f8-93e8-11e8-9d92-a82adde7718f.png)](https://egghead.io/courses/beautiful-and-accessible-drag-and-drop-with-react-beautiful-dnd)
## Currently supported feature set ✅
- Vertical lists ↕
- Horizontal lists ↔
- Movement between lists (▤ ↔ ▤)
- [Virtual list support 👾](/docs/patterns/virtual-lists.md) - unlocking 10,000 items @ 60fps
- [Combining items](/docs/guides/combining.md)
- Mouse 🐭, keyboard 🎹♿️ and touch 👉📱 (mobile, tablet and so on) support
- [Multi drag support](/docs/patterns/multi-drag.md)
- Incredible screen reader support ♿️ - we provide an amazing experience for english screen readers out of the box 📦. We also provide complete customisation control and internationalisation support for those who need it 💖
- [Conditional dragging](/docs/api/draggable.md#optional-props) and [conditional dropping](/docs/api/droppable.md#conditionally-dropping)
- Multiple independent lists on the one page
- Flexible item sizes - the draggable items can have different heights (vertical lists) or widths (horizontal lists)
- [Add and remove items during a drag](/docs/guides/changes-while-dragging.md)
- Compatible with semantic `<table>` reordering - [table pattern](/docs/patterns/tables.md)
- [Auto scrolling](/docs/guides/auto-scrolling.md) - automatically scroll containers and the window as required during a drag (even with keyboard 🔥)
- Custom drag handles - you can drag a whole item by just a part of it
- Able to move the dragging item to another element while dragging (clone, portal) - [Reparenting your `<Draggable />`](/docs/guides/reparenting.md)
- [Create scripted drag and drop experiences 🎮](/docs/sensors/sensor-api.md)
- Allows extensions to support for [any input type you like 🕹](/docs/sensors/sensor-api.md)
- 🌲 Tree support through the [`@atlaskit/tree`](https://atlaskit.atlassian.com/packages/confluence/tree) package
- A `<Droppable />` list can be a scroll container (without a scrollable parent) or be the child of a scroll container (that also does not have a scrollable parent)
- Independent nested lists - a list can be a child of another list, but you cannot drag items from the parent list into a child list
- Server side rendering (SSR) compatible - see [resetServerContext()](/docs/api/reset-server-context.md)
- Plays well with [nested interactive elements](/docs/api/draggable.md#interactive-child-elements-within-a-draggable-) by default
## Motivation 🤔
`@hello-pangea/dnd` exists to create beautiful drag and drop for lists that anyone can use - even people who cannot see. For a good overview of the history and motivations of the project you can take a look at these external resources:
- 📖 [Rethinking drag and drop](https://medium.com/@alexandereardon/rethinking-drag-and-drop-d9f5770b4e6b)
- 🎧 [React podcast: fast, accessible and beautiful drag and drop](https://reactpodcast.simplecast.fm/17)
## Not for everyone ✌️
There are a lot of libraries out there that allow for drag and drop interactions within React. Most notable of these is the amazing [`react-dnd`](https://github.com/react-dnd/react-dnd). It does an incredible job at providing a great set of drag and drop primitives which work especially well with the [wildly inconsistent](https://www.quirksmode.org/blog/archives/2009/09/the_html5_drag.html) html5 drag and drop feature. `@hello-pangea/dnd` is a higher level abstraction specifically built for lists (vertical, horizontal, movement between lists, nested lists and so on). Within that subset of functionality `@hello-pangea/dnd` offers a powerful, natural and beautiful drag and drop experience. However, it does not provide the breadth of functionality offered by `react-dnd`. One shortcoming is that grid layouts are not supported (yet). So `@hello-pangea/dnd` might not be for you depending on what your use case is.
## Documentation 📖
### About 👋
- [Installation](/docs/about/installation.md)
- [Examples and samples](/docs/about/examples.md)
- [Get started](https://egghead.io/courses/beautiful-and-accessible-drag-and-drop-with-react-beautiful-dnd) (This is using [react-beautiful-dnd](https://github.com/atlassian/react-beautiful-dnd))
- [Design principles](/docs/about/design-principles.md)
- [Animations](/docs/about/animations.md)
- [Accessibility](/docs/about/accessibility.md)
- [Browser support](/docs/about/browser-support.md)
### Sensors 🔉
> The ways in which somebody can start and control a drag
- [Mouse dragging 🐭](/docs/sensors/mouse.md)
- [Touch dragging 👉📱](/docs/sensors/touch.md)
- [Keyboard dragging 🎹♿️](/docs/sensors/keyboard.md)
- [Create your own sensor](/docs/sensors/sensor-api.md) (allows for any input type as well as scripted experiences)
### API 🏋️
![diagram](https://user-images.githubusercontent.com/2182637/53607406-c8f3a780-3c12-11e9-979c-7f3b5bd1bfbd.gif)
- [`<DragDropContext />`](/docs/api/drag-drop-context.md) - _Wraps the part of your application you want to have drag and drop enabled for_
- [`<Droppable />`](/docs/api/droppable.md) - _An area that can be dropped into. Contains `<Draggable />`s_
- [`<Draggable />`](/docs/api/draggable.md) - _What can be dragged around_
- [`resetServerContext()`](/docs/api/reset-server-context.md) - _Utility for server side rendering (SSR)_
### Guides 🗺
- [`<DragDropContext />` responders](/docs/guides/responders.md) - _`onDragStart`, `onDragUpdate`, `onDragEnd` and `onBeforeDragStart`_
- [Combining `<Draggable />`s](/docs/guides/combining.md)
- [Common setup issues](/docs/guides/common-setup-issues.md)
- [Using `innerRef`](/docs/guides/using-inner-ref.md)
- [Setup problem detection and error recovery](/docs/guides/setup-problem-detection-and-error-recovery.md)
- [Rules for `draggableId` and `droppableId`s](/docs/guides/identifiers.md)
- [Browser focus retention](/docs/guides/browser-focus.md)
- [Customising or skipping the drop animation](/docs/guides/drop-animation.md)
- [Auto scrolling](/docs/guides/auto-scrolling.md)
- [Controlling the screen reader](/docs/guides/screen-reader.md)
- [Use the html5 `doctype`](/docs/guides/doctype.md)
- [`TypeScript`: type information](/docs/guides/types.md)
- [Dragging `<svg>`s](/docs/guides/dragging-svgs.md)
- [Avoiding image flickering](/docs/guides/avoiding-image-flickering.md)
- [Non-visible preset styles](/docs/guides/preset-styles.md)
- [How we detect scroll containers](/docs/guides/how-we-detect-scroll-containers.md)
- [How we use dom events](/docs/guides/how-we-use-dom-events.md) - _Useful if you need to build on top of `@hello-pangea/dnd`_
- [Adding `<Draggable />`s during a drag (11.x behaviour)](/docs/guides/changes-while-dragging.md) - _⚠️ Advanced_
- [Setting up Content Security Policy](/docs/guides/content-security-policy.md)
### Patterns 👷‍
- [Virtual lists 👾](/docs/patterns/virtual-lists.md)
- [Multi drag](/docs/patterns/multi-drag.md)
- [Tables](/docs/patterns/tables.md)
- [Reparenting a `<Draggable />`](/docs/guides/reparenting.md) - _Using our cloning API or your own portal_
### Support 👩‍⚕️
- [Engineering health](/docs/support/engineering-health.md)
- [Community and addons](/docs/support/community-and-addons.md)
- [Release notes and changelog](https://github.com/hello-pangea/dnd/releases)
- [Upgrading](/docs/support/upgrading.md)
- [Road map](https://github.com/hello-pangea/dnd/issues)
- [Media](/docs/support/media.md)
## Read this in other languages 🌎
⚠️ These following translations are based on [react-beautiful-dnd](https://github.com/atlassian/react-beautiful-dnd).
- [![kr](https://raw.githubusercontent.com/gosquared/flags/master/flags/flags/shiny/24/South-Korea.png) **한글/Korean**](https://github.com/LeeHyungGeun/react-beautiful-dnd-kr)
- [![ru](https://raw.githubusercontent.com/gosquared/flags/master/flags/flags/shiny/24/Russia.png) **На русском/Russian**](https://github.com/vtereshyn/react-beautiful-dnd-ru)
- [![pt](https://raw.githubusercontent.com/gosquared/flags/master/flags/flags/shiny/24/Brazil.png) **Português/Portuguese**](https://github.com/dudestein/react-beautiful-dnd-pt)
- [![gr](https://raw.githubusercontent.com/gosquared/flags/master/flags/flags/shiny/24/Greece.png) **Ελληνικά/Greek**](https://github.com/milvard/react-beautiful-dnd-gr)
## Creator ✍️
Alex Reardon [@alexandereardon](https://twitter.com/alexandereardon)
> Alex is no longer personally maintaning this project. The other wonderful maintainers are carrying this project forward.
## Maintainers 🛠️
- Gabriel Santerre [@100terres](https://github.com/100terres)
- Reece Carolan [@Xhale1](https://github.com/Xhale1)
- Many [@Atlassian](https://twitter.com/Atlassian)'s have contributed to the original [`react-beautiful-dnd`](https://github.com/atlassian/react-beautiful-dnd). _Atlassian is no longer involved with this project._
## Collaborators 🤝
- Bogdan Chadkin [@IAmTrySound](https://twitter.com/IAmTrySound)
## Thanks 🤗
<a href="https://www.chromatic.com/"><img src="https://user-images.githubusercontent.com/321738/84662277-e3db4f80-af1b-11ea-88f5-91d67a5e59f6.png" width="153" height="30" alt="Chromatic" /></a>
Thanks to [Chromatic](https://www.chromatic.com/) for providing the visual testing platform that helps us review UI changes and catch visual regressions.
+7452
View File
File diff suppressed because it is too large Load Diff
+266
View File
@@ -0,0 +1,266 @@
import { Position } from 'css-box-model';
import React, { ReactNode, TransitionEventHandler, DragEventHandler, FunctionComponent } from 'react';
type Id<TId extends string = string> = TId;
type DraggableId<TId extends string = string> = Id<TId>;
type DroppableId<TId extends string = string> = Id<TId>;
type TypeId<TId extends string = string> = Id<TId>;
type ContextId<TId extends string = string> = Id<TId>;
type ElementId<TId extends string = string> = Id<TId>;
type DroppableMode = 'standard' | 'virtual';
interface DraggableOptions {
canDragInteractiveElements: boolean;
shouldRespectForcePress: boolean;
isEnabled: boolean;
}
type Direction = 'horizontal' | 'vertical';
interface DraggableLocation<TId extends string = string> {
droppableId: DroppableId<TId>;
index: number;
}
interface Combine<TId extends string = string> {
draggableId: DraggableId<TId>;
droppableId: DroppableId<TId>;
}
type MovementMode = 'FLUID' | 'SNAP';
interface DraggableRubric<TId extends string = string> {
draggableId: DraggableId<TId>;
type: TypeId<TId>;
source: DraggableLocation<TId>;
}
interface BeforeCapture<TId extends string = string> {
draggableId: DraggableId<TId>;
mode: MovementMode;
}
interface DragStart<TId extends string = string> extends DraggableRubric<TId> {
mode: MovementMode;
}
interface DragUpdate<TId extends string = string> extends DragStart<TId> {
destination: DraggableLocation<TId> | null;
combine: Combine<TId> | null;
}
type DropReason = 'DROP' | 'CANCEL';
interface DropResult<TId extends string = string> extends DragUpdate<TId> {
reason: DropReason;
}
type Announce = (message: string) => void;
interface ResponderProvided {
announce: Announce;
}
type OnBeforeCaptureResponder<TId extends string = string> = (before: BeforeCapture<TId>) => void;
type OnBeforeDragStartResponder<TId extends string = string> = (start: DragStart<TId>) => void;
type OnDragStartResponder<TId extends string = string> = (start: DragStart<TId>, provided: ResponderProvided) => void;
type OnDragUpdateResponder<TId extends string = string> = (update: DragUpdate<TId>, provided: ResponderProvided) => void;
type OnDragEndResponder<TId extends string = string> = (result: DropResult<TId>, provided: ResponderProvided) => void;
interface Responders<TId extends string = string> {
onBeforeCapture?: OnBeforeCaptureResponder<TId>;
onBeforeDragStart?: OnBeforeDragStartResponder<TId>;
onDragStart?: OnDragStartResponder<TId>;
onDragUpdate?: OnDragUpdateResponder<TId>;
onDragEnd: OnDragEndResponder<TId>;
}
interface StopDragOptions {
shouldBlockNextClick: boolean;
}
interface DragActions {
drop: (args?: StopDragOptions) => void;
cancel: (args?: StopDragOptions) => void;
isActive: () => boolean;
shouldRespectForcePress: () => boolean;
}
interface FluidDragActions extends DragActions {
move: (clientSelection: Position) => void;
}
interface SnapDragActions extends DragActions {
moveUp: () => void;
moveDown: () => void;
moveRight: () => void;
moveLeft: () => void;
}
interface PreDragActions {
isActive: () => boolean;
shouldRespectForcePress: () => boolean;
fluidLift: (clientSelection: Position) => FluidDragActions;
snapLift: () => SnapDragActions;
abort: () => void;
}
interface TryGetLockOptions {
sourceEvent?: Event;
}
type TryGetLock<TId extends string = string> = (draggableId: DraggableId<TId>, forceStop?: () => void, options?: TryGetLockOptions) => PreDragActions | null;
interface SensorAPI<TId extends string = string> {
tryGetLock: TryGetLock<TId>;
canGetLock: (id: DraggableId<TId>) => boolean;
isLockClaimed: () => boolean;
tryReleaseLock: () => void;
findClosestDraggableId: (event: Event) => DraggableId<TId> | null;
findOptionsForDraggable: (id: DraggableId<TId>) => DraggableOptions | null;
}
type Sensor<TId extends string = string> = (api: SensorAPI<TId>) => void;
type RecursivePartial<T> = {
[P in keyof T]?: T[P] extends (infer U)[] ? RecursivePartial<U>[] : T[P] extends (...args: any) => any ? T[P] | undefined : T[P] extends object ? RecursivePartial<T[P]> : T[P];
};
/**
* Customize autoScroller behavior
*/
interface AutoScrollerOptions {
/**
* Percentage distance from edge of container at which to start auto scrolling.
* ex. 0.1 or 0.9
*/
startFromPercentage: number;
/**
* Percentage distance from edge of container at which max scroll speed is achieved.
* Should be less than startFromPercentage
*/
maxScrollAtPercentage: number;
/**
* Maximum pixels to scroll per frame
*/
maxPixelScroll: number;
/**
* A function used to ease a percentage value
* A simple linear function would be: (percentage) => percentage;
* percentage is between 0 and 1
* result must be between 0 and 1
*/
ease: (percentage: number) => number;
durationDampening: {
/**
* How long to dampen the speed of an auto scroll from the start of a drag in milliseconds
*/
stopDampeningAt: number;
/**
* When to start accelerating the reduction of duration dampening in milliseconds
*/
accelerateAt: number;
};
/**
* Whether or not autoscroll should be turned off entirely
*/
disabled: boolean;
}
type PartialAutoScrollerOptions = RecursivePartial<AutoScrollerOptions>;
interface DragDropContextProps extends Responders {
children: ReactNode | null;
dragHandleUsageInstructions?: string;
enableDefaultSensors?: boolean | null;
nonce?: string;
sensors?: Sensor[];
/**
* Customize auto scroller
*/
autoScrollerOptions?: PartialAutoScrollerOptions;
}
declare function DragDropContext(props: DragDropContextProps): React.JSX.Element;
interface DraggingStyle {
position: 'fixed';
top: number;
left: number;
boxSizing: 'border-box';
width: number;
height: number;
transition: string;
transform?: string;
zIndex: number;
opacity?: number;
pointerEvents: 'none';
}
interface NotDraggingStyle {
transform?: string;
transition?: 'none';
}
type DraggableStyle = DraggingStyle | NotDraggingStyle;
interface DraggableProvidedDraggableProps {
style?: DraggableStyle;
'data-rfd-draggable-context-id': ContextId;
'data-rfd-draggable-id': DraggableId;
onTransitionEnd?: TransitionEventHandler;
}
interface DraggableProvidedDragHandleProps {
'data-rfd-drag-handle-draggable-id': DraggableId;
'data-rfd-drag-handle-context-id': ContextId;
role: string;
'aria-describedby': ElementId;
tabIndex: number;
draggable: boolean;
onDragStart: DragEventHandler;
}
interface DraggableProvided {
draggableProps: DraggableProvidedDraggableProps;
dragHandleProps: DraggableProvidedDragHandleProps | null;
innerRef: (element?: HTMLElement | null) => void;
}
interface DropAnimation {
duration: number;
curve: string;
moveTo: Position;
opacity: number | null;
scale: number | null;
}
interface DraggableStateSnapshot {
isDragging: boolean;
isDropAnimating: boolean;
isClone: boolean;
dropAnimation: DropAnimation | null;
draggingOver: DroppableId | null;
combineWith: DraggableId | null;
combineTargetFor: DraggableId | null;
mode: MovementMode | null;
}
type DraggableChildrenFn = (provided: DraggableProvided, snapshot: DraggableStateSnapshot, rubic: DraggableRubric) => ReactNode | null;
interface DraggableProps {
draggableId: DraggableId;
index: number;
children: DraggableChildrenFn;
isDragDisabled?: boolean;
disableInteractiveElementBlocking?: boolean;
shouldRespectForcePress?: boolean;
}
declare function PublicDraggable(props: DraggableProps): React.JSX.Element;
interface DroppableProvidedProps {
'data-rfd-droppable-context-id': ContextId;
'data-rfd-droppable-id': DroppableId;
}
interface DroppableProvided {
innerRef: (a?: HTMLElement | null) => void;
placeholder: ReactNode | null;
droppableProps: DroppableProvidedProps;
}
interface DroppableStateSnapshot {
isDraggingOver: boolean;
draggingOverWith: DraggableId | null;
draggingFromThisWith: DraggableId | null;
isUsingPlaceholder: boolean;
}
interface DefaultProps {
direction: Direction;
getContainerForClone: () => HTMLElement;
ignoreContainerClipping: boolean;
isCombineEnabled: boolean;
isDropDisabled: boolean;
mode: DroppableMode;
type: TypeId;
renderClone: DraggableChildrenFn | null;
}
interface DroppableProps extends Partial<DefaultProps> {
children: (provided: DroppableProvided, snapshot: DroppableStateSnapshot) => ReactNode;
droppableId: DroppableId;
renderClone?: DraggableChildrenFn | null;
}
declare const ConnectedDroppable: FunctionComponent<DroppableProps>;
declare function useMouseSensor(api: SensorAPI): void;
declare function useTouchSensor(api: SensorAPI): void;
declare function useKeyboardSensor(api: SensorAPI): void;
export { type Announce, type BeforeCapture, type Direction, DragDropContext, type DragDropContextProps, type DragStart, type DragUpdate, PublicDraggable as Draggable, type DraggableChildrenFn, type DraggableId, type DraggableLocation, type DraggableProps, type DraggableProvided, type DraggableProvidedDragHandleProps, type DraggableProvidedDraggableProps, type DraggableRubric, type DraggableStateSnapshot, type DraggableStyle, type DraggingStyle, type DropAnimation, type DropResult, ConnectedDroppable as Droppable, type DroppableId, type DroppableProps, type DroppableProvided, type DroppableProvidedProps, type DroppableStateSnapshot, type FluidDragActions, type Id, type MovementMode, type NotDraggingStyle, type OnBeforeCaptureResponder, type OnBeforeDragStartResponder, type OnDragEndResponder, type OnDragStartResponder, type OnDragUpdateResponder, type PreDragActions, type ResponderProvided, type Sensor, type SensorAPI, type SnapDragActions, type TryGetLock, type TryGetLockOptions, type TypeId, useKeyboardSensor, useMouseSensor, useTouchSensor };
+7445
View File
File diff suppressed because it is too large Load Diff
+9128
View File
File diff suppressed because it is too large Load Diff
+10
View File
File diff suppressed because one or more lines are too long
+207
View File
@@ -0,0 +1,207 @@
{
"name": "@hello-pangea/dnd",
"version": "17.0.0",
"private": false,
"description": "Beautiful and accessible drag and drop for lists with React",
"author": "Alex Reardon <areardon@atlassian.com>",
"maintainers": [
"Gabriel Santerre <gab@100terres.com>",
"Reece Carolan <reece@hellopangea.com>"
],
"keywords": [
"drag and drop",
"dnd",
"sortable",
"reorder",
"reorderable",
"react",
"react.js",
"natural",
"beautiful",
"accessible"
],
"repository": {
"type": "git",
"url": "https://github.com/hello-pangea/dnd.git"
},
"bugs": {
"url": "https://github.com/hello-pangea/dnd/issues"
},
"main": "dist/dnd.cjs.js",
"module": "dist/dnd.esm.js",
"types": "dist/dnd.d.ts",
"sideEffects": false,
"files": [
"/dist",
"/src"
],
"publishConfig": {
"access": "public"
},
"config": {
"prettier_target": "*.{js,jsx,ts,tsx,md,json} src/**/*.{js,jsx,ts,tsx,md,json} test/**/*.{js,jsx,ts,tsx,md,json} docs/**/*.{js,jsx,ts,tsx,md,json} stories/**/*.{js,jsx,ts,tsx,md,json} cypress/**/*.{js,jsx,ts,tsx,md,json} csp-server/**/*.{js,jsx,ts,tsx,md,json}"
},
"scripts": {
"commit": "cz",
"chromatic": "chromatic --project-token=f92123f238de",
"prepare": "husky",
"release": "release-it",
"release:test": "release-it --dry-run",
"test:accessibility": "lighthouse http://localhost:9002/iframe.html?id=examples-single-vertical-list--basic --no-enable-error-reporting --config-path=lighthouse.config.js --chrome-flags='--headless' --output=json --output=html --output-path=./test-reports/lighthouse/a11y.json && node a11y-audit-parse.js",
"test": "jest --config ./jest.config.ts",
"test:browser": "cypress open",
"test:browser:ci": "cypress run",
"test:coverage": "pnpm test --coverage --coveragePathIgnorePatterns=/debug",
"validate": "pnpm prettier:check && pnpm lint:eslint && pnpm lint:css && pnpm typecheck",
"prettier:check": "pnpm prettier --debug-check $npm_package_config_prettier_target",
"prettier:write": "pnpm prettier --write $npm_package_config_prettier_target",
"lint:eslint": "pnpm eslint \"./**/*.{js,jsx,ts,tsx}\"",
"lint:css": "stylelint \"stories/**/*.{js,jsx,ts,tsx}\"",
"typecheck": "pnpm typecheck:lib && pnpm typecheck:test && pnpm typecheck:storybook",
"typecheck:lib": "pnpm tsc --project tsconfig.json",
"typecheck:storybook": "pnpm tsc --project stories/tsconfig.json && pnpm tsc --project .storybook/tsconfig.json",
"typecheck:test": "pnpm tsc --project test/tsconfig.json && pnpm tsc --project csp-server/tsconfig.json && pnpm tsc --project cypress/tsconfig.json",
"bundle-size:check": "cross-env SNAPSHOT=match pnpm bundle-size:update",
"bundle-size:update": "pnpm build:clean && pnpm build:dist && pnpm build:clean",
"build": "pnpm build:clean && pnpm build:dist",
"build:clean": "rimraf dist",
"build:dist": "cross-env NODE_ENV=production rollup -c",
"storybook": "storybook dev -p 9002",
"build-storybook": "storybook build -c .storybook -o site",
"prepublishOnly": "pnpm build"
},
"dependencies": {
"@babel/runtime": "^7.25.6",
"css-box-model": "^1.2.1",
"memoize-one": "^6.0.0",
"raf-schd": "^4.0.3",
"react-redux": "^9.1.2",
"redux": "^5.0.1",
"use-memo-one": "^1.1.3"
},
"devDependencies": {
"@atlaskit/css-reset": "6.10.1",
"@atlaskit/theme": "13.0.0",
"@babel/core": "7.25.2",
"@babel/eslint-parser": "7.25.1",
"@babel/plugin-transform-class-properties": "7.25.4",
"@babel/plugin-transform-modules-commonjs": "7.24.8",
"@babel/plugin-transform-object-assign": "7.24.7",
"@babel/plugin-transform-private-methods": "7.25.4",
"@babel/plugin-transform-private-property-in-object": "7.24.7",
"@babel/plugin-transform-runtime": "7.25.4",
"@babel/preset-env": "7.25.4",
"@babel/preset-react": "7.24.7",
"@babel/preset-typescript": "7.24.7",
"@commitlint/cli": "19.5.0",
"@commitlint/config-conventional": "19.5.0",
"@commitlint/cz-commitlint": "19.5.0",
"@emotion/eslint-plugin": "11.12.0",
"@emotion/react": "11.13.3",
"@emotion/styled": "11.13.0",
"@jest/environment": "29.7.0",
"@release-it/conventional-changelog": "8.0.2",
"@rollup/plugin-babel": "6.0.4",
"@rollup/plugin-commonjs": "25.0.8",
"@rollup/plugin-json": "6.1.0",
"@rollup/plugin-node-resolve": "15.2.3",
"@rollup/plugin-replace": "5.0.7",
"@rollup/plugin-strip": "3.0.4",
"@rollup/plugin-terser": "0.4.4",
"@storybook/addon-docs": "8.3.0",
"@storybook/addon-essentials": "8.3.0",
"@storybook/addon-storysource": "8.3.0",
"@storybook/addon-webpack5-compiler-swc": "1.0.5",
"@storybook/manager-api": "8.3.0",
"@storybook/react": "8.3.0",
"@storybook/react-webpack5": "8.3.0",
"@storybook/theming": "8.3.0",
"@swc/core": "1.7.26",
"@testing-library/dom": "9.3.4",
"@testing-library/jest-dom": "6.5.0",
"@testing-library/react": "14.3.1",
"@types/express": "4.17.21",
"@types/fs-extra": "11.0.4",
"@types/jest": "29.5.13",
"@types/jest-axe": "3.5.9",
"@types/jsdom": "21.1.7",
"@types/markdown-it": "13.0.9",
"@types/node": "20.16.5",
"@types/raf-schd": "4.0.3",
"@types/react": "18.3.5",
"@types/react-dom": "18.3.0",
"@types/react-virtualized": "9.21.30",
"@types/react-window": "1.8.8",
"@types/seedrandom": "3.0.8",
"@typescript-eslint/eslint-plugin": "7.18.0",
"@typescript-eslint/parser": "7.18.0",
"babel-jest": "29.7.0",
"babel-loader": "9.1.3",
"babel-plugin-dev-expression": "0.2.3",
"babel-plugin-module-resolver": "5.0.2",
"browserslist": "4.23.3",
"commitizen": "4.3.0",
"cross-env": "7.0.3",
"csstype": "3.1.3",
"cypress": "13.14.2",
"dotenv": "16.4.5",
"eslint": "8.57.0",
"eslint-config-airbnb": "19.0.4",
"eslint-config-prettier": "9.1.0",
"eslint-import-resolver-typescript": "3.6.3",
"eslint-plugin-cypress": "2.15.2",
"eslint-plugin-es5": "1.5.0",
"eslint-plugin-import": "2.30.0",
"eslint-plugin-jest": "27.9.0",
"eslint-plugin-jsx-a11y": "6.10.0",
"eslint-plugin-node": "11.1.0",
"eslint-plugin-prettier": "5.2.1",
"eslint-plugin-react": "7.36.1",
"eslint-plugin-react-hooks": "4.6.2",
"eslint-plugin-storybook": "0.8.0",
"express": "4.20.0",
"fast-glob": "3.3.2",
"fs-extra": "11.2.0",
"husky": "9.1.6",
"jest": "29.7.0",
"jest-axe": "9.0.0",
"jest-environment-jsdom": "29.7.0",
"jest-junit": "16.0.0",
"jest-watch-typeahead": "2.2.2",
"jsdom": "25.0.0",
"lighthouse": "11.7.1",
"markdown-it": "14.1.0",
"memory-fs": "0.5.0",
"postcss-styled-syntax": "0.6.4",
"prettier": "3.3.3",
"raf-stub": "3.0.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-virtualized": "9.22.5",
"react-window": "1.8.10",
"release-it": "17.6.0",
"require-from-string": "2.0.2",
"rimraf": "6.0.1",
"rollup": "4.21.3",
"rollup-plugin-dts": "6.1.1",
"seedrandom": "3.0.5",
"storybook": "8.3.0",
"styled-components": "6.1.13",
"stylelint": "16.9.0",
"stylelint-config-recommended": "14.0.1",
"stylelint-config-standard": "36.0.1",
"ts-node": "^10.9.2",
"typescript": "5.6.2",
"wait-on": "8.0.1",
"webpack": "5.94.0"
},
"peerDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
},
"license": "Apache-2.0",
"jest-junit": {
"output": "test-reports/junit/js-test-results.xml"
},
"packageManager": "pnpm@9.10.0"
}
+76
View File
@@ -0,0 +1,76 @@
import type { Position } from 'css-box-model';
import { isEqual, origin } from './state/position';
export const curves = {
outOfTheWay: 'cubic-bezier(0.2, 0, 0, 1)',
drop: 'cubic-bezier(.2,1,.1,1)',
};
export const combine = {
opacity: {
// while dropping: fade out totally
drop: 0,
// while dragging: fade out partially
combining: 0.7,
},
scale: {
drop: 0.75,
},
};
export const timings = {
outOfTheWay: 0.2,
// greater than the out of the way time
// so that when the drop ends everything will
// have to be out of the way
minDropTime: 0.33,
maxDropTime: 0.55,
};
// slow timings
// uncomment to use
// export const timings = {
// outOfTheWay: 2,
// // greater than the out of the way time
// // so that when the drop ends everything will
// // have to be out of the way
// minDropTime: 3,
// maxDropTime: 4,
// };
const outOfTheWayTiming = `${timings.outOfTheWay}s ${curves.outOfTheWay}`;
export const placeholderTransitionDelayTime = 0.1;
export const transitions = {
fluid: `opacity ${outOfTheWayTiming}`,
snap: `transform ${outOfTheWayTiming}, opacity ${outOfTheWayTiming}`,
drop: (duration: number): string => {
const timing = `${duration}s ${curves.drop}`;
return `transform ${timing}, opacity ${timing}`;
},
outOfTheWay: `transform ${outOfTheWayTiming}`,
placeholder: `height ${outOfTheWayTiming}, width ${outOfTheWayTiming}, margin ${outOfTheWayTiming}`,
};
const moveTo = (offset: Position): string | undefined =>
isEqual(offset, origin)
? undefined
: `translate(${offset.x}px, ${offset.y}px)`;
export const transforms = {
moveTo,
drop: (offset: Position, isCombining: boolean): string | undefined => {
const translate: string | undefined = moveTo(offset);
if (!translate) {
return undefined;
}
// only transforming the translate
if (!isCombining) {
return translate;
}
// when dropping while combining we also update the scale
return `${translate} scale(${combine.scale.drop})`;
},
};
@@ -0,0 +1,53 @@
/* eslint-disable no-console */
import type { Action } from '../../state/store-types';
interface Bucket {
[key: string]: number[];
}
const average = (values: number[]): number => {
const sum: number = values.reduce(
(previous: number, current: number) => previous + current,
0,
);
return sum / values.length;
};
export default (groupSize: number) => {
console.log('Starting average action timer middleware');
console.log(`Will take an average every ${groupSize} actions`);
const bucket: Bucket = {};
return () =>
(next: (a: Action) => unknown) =>
(action: Action): any => {
const start: number = performance.now();
const result: unknown = next(action);
const end: number = performance.now();
const duration: number = end - start;
if (!bucket[action.type]) {
bucket[action.type] = [duration];
return result;
}
bucket[action.type].push(duration);
if (bucket[action.type].length < groupSize) {
return result;
}
console.warn(
`Average time for ${action.type}`,
average(bucket[action.type]),
);
// reset
bucket[action.type] = [];
return result;
};
};
+17
View File
@@ -0,0 +1,17 @@
/* eslint-disable no-console */
import * as timings from '../timings';
import type { Action } from '../../state/store-types';
export default () =>
(next: (a: Action) => unknown) =>
(action: Action): any => {
timings.forceEnable();
const key = `redux action: ${action.type}`;
timings.start(key);
const result: unknown = next(action);
timings.finish(key);
return result;
};
+26
View File
@@ -0,0 +1,26 @@
/* eslint-disable no-console */
import type { Action, Store } from '../../state/store-types';
type Mode = 'verbose' | 'light';
export default (mode: Mode = 'verbose') =>
(store: Store) =>
(next: (a: Action) => unknown) =>
(action: Action): any => {
if (mode === 'light') {
console.log('🏃‍ Action:', action.type);
return next(action);
}
console.group(`action: ${action.type}`);
console.log('action payload', action.payload);
console.log('state before', store.getState());
const result: unknown = next(action);
console.log('state after', store.getState());
console.groupEnd();
return result;
};
+17
View File
@@ -0,0 +1,17 @@
import type { Action } from '../../state/store-types';
export default () =>
(next: (a: Action) => unknown) =>
(action: Action): any => {
const title = `👾 redux (action): ${action.type}`;
const startMark = `${action.type}:start`;
const endMark = `${action.type}:end`;
performance.mark(startMark);
const result: unknown = next(action);
performance.mark(endMark);
performance.measure(title, startMark, endMark);
return result;
};
+85
View File
@@ -0,0 +1,85 @@
interface Records {
[key: string]: number;
}
const records: Records = {};
let isEnabled = false;
const isTimingsEnabled = (): boolean => isEnabled;
export const forceEnable = () => {
isEnabled = true;
};
// Debug: uncomment to enable
// forceEnable();
export const start = (key: string) => {
// we want to strip all the code out for production builds
// draw back: can only do timings in dev env (which seems to be fine for now)
if (process.env.NODE_ENV !== 'production') {
if (!isTimingsEnabled()) {
return;
}
const now: number = performance.now();
records[key] = now;
}
};
interface Style {
textColor: string;
symbol: string;
}
export const finish = (key: string) => {
if (process.env.NODE_ENV !== 'production') {
if (!isTimingsEnabled()) {
return;
}
const now: number = performance.now();
const previous: number | null = records[key];
if (!previous) {
// eslint-disable-next-line no-console
console.warn('cannot finish timing as no previous time found', key);
return;
}
const result: number = now - previous;
const rounded: string = result.toFixed(2);
const style: Style = (() => {
if (result < 12) {
return {
textColor: 'green',
symbol: '✅',
};
}
if (result < 40) {
return {
textColor: 'orange',
symbol: '⚠️',
};
}
return {
textColor: 'red',
symbol: '❌',
};
})();
// eslint-disable-next-line no-console
console.log(
`${style.symbol} %cTiming %c${rounded} %cms %c${key}`,
// title
'color: blue; font-weight: bold;',
// result
`color: ${style.textColor}; font-size: 1.1em;`,
// ms
'color: grey;',
// key
'color: purple; font-weight: bold;',
);
}
};
+48
View File
@@ -0,0 +1,48 @@
const isProduction: boolean = process.env.NODE_ENV === 'production';
// not replacing newlines (which \s does)
const spacesAndTabs = /[ \t]{2,}/g;
const lineStartWithSpaces = /^[ \t]*/gm;
// using .trim() to clear the any newlines before the first text and after last text
const clean = (value: string): string =>
value.replace(spacesAndTabs, ' ').replace(lineStartWithSpaces, '').trim();
const getDevMessage = (message: string): string =>
clean(`
%c@hello-pangea/dnd
%c${clean(message)}
%c👷‍ This is a development only message. It will be removed in production builds.
`);
export const getFormattedMessage = (message: string): string[] => [
getDevMessage(message),
// title (green400)
'color: #00C584; font-size: 1.2em; font-weight: bold;',
// message
'line-height: 1.5',
// footer (purple300)
'color: #723874;',
];
const isDisabledFlag = '__@hello-pangea/dnd-disable-dev-warnings';
export function log(type: 'error' | 'warn', message: string): void {
// no warnings in production
if (isProduction) {
return;
}
// manual opt out of warnings
if (typeof window !== 'undefined' && window[isDisabledFlag]) {
return;
}
// eslint-disable-next-line no-console
console[type](...getFormattedMessage(message));
}
export const warning = log.bind(null, 'warn');
export const error = log.bind(null, 'error');
+5
View File
@@ -0,0 +1,5 @@
export function noop(): void {}
export function identity<T>(value: T): T {
return value;
}
+27
View File
@@ -0,0 +1,27 @@
declare type AnimationFrameID = number;
declare type TimeoutID = number;
declare type IntervalID = number;
interface ProcessEnv {
NODE_ENV: 'development' | 'production';
}
interface Process {
env: ProcessEnv;
}
declare const process: Process;
declare interface Window {
'__@hello-pangea/dnd-disable-dev-warnings'?: boolean;
}
// From the test folder we explicitly set typescript to load node.
// From the stories folder @storybook/react load NodeJS types.
// See: https://github.com/storybookjs/storybook/issues/12969#issue-733867804
// Loading node can causes issues similar to what is mention below
// See: https://github.com/Microsoft/TypeScript/issues/842
// In order to fix the problem we override some node types
declare namespace NodeJS {
declare type Timeout = TimeoutID;
}
+64
View File
@@ -0,0 +1,64 @@
// Public types
export type {
Announce,
BeforeCapture,
Direction,
DraggableId,
DraggableRubric,
DragStart,
DragUpdate,
DraggableLocation,
DroppableId,
DropResult,
FluidDragActions,
Id,
MovementMode,
OnBeforeCaptureResponder,
OnBeforeDragStartResponder,
OnDragEndResponder,
OnDragStartResponder,
OnDragUpdateResponder,
PreDragActions,
ResponderProvided,
SensorAPI,
Sensor,
SnapDragActions,
TypeId,
TryGetLock,
TryGetLockOptions,
} from './types';
// DragDropContext
export { default as DragDropContext } from './view/drag-drop-context';
export type { DragDropContextProps } from './view/drag-drop-context';
// Draggable
export { default as Draggable } from './view/draggable';
export type {
DraggableChildrenFn,
DraggableProps,
DraggableProvided,
DraggableProvidedDraggableProps,
DraggableProvidedDragHandleProps,
DraggableStateSnapshot,
DraggableStyle,
DraggingStyle,
DropAnimation,
NotDraggingStyle,
} from './view/draggable/draggable-types';
// Droppable
export { default as Droppable } from './view/droppable';
export type {
DroppableProps,
DroppableProvided,
DroppableProvidedProps,
DroppableStateSnapshot,
} from './view/droppable/droppable-types';
// Sensors
export {
useMouseSensor,
useTouchSensor,
useKeyboardSensor,
} from './view/use-sensor-marshal';
+32
View File
@@ -0,0 +1,32 @@
/* eslint-disable no-restricted-syntax */
const isProduction: boolean = process.env.NODE_ENV === 'production';
const prefix = 'Invariant failed';
export class RbdInvariant extends Error {}
RbdInvariant.prototype.toString = function toString() {
return this.message;
};
// A copy-paste of tiny-invariant but with a custom error type
// Throw an error if the condition fails
export function invariant(condition: false, message?: string): never;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function invariant(condition: any, message?: string): asserts condition;
export function invariant(
condition: unknown,
message?: string,
): asserts condition {
if (condition) {
return;
}
if (isProduction) {
// In production we strip the message but still throw
throw new RbdInvariant(prefix);
} else {
// When not in production we allow the message to pass through
// *This block will be removed in production builds*
throw new RbdInvariant(`${prefix}: ${message || ''}`);
}
}
+9
View File
@@ -0,0 +1,9 @@
export type RecursivePartial<T> = {
[P in keyof T]?: T[P] extends (infer U)[]
? RecursivePartial<U>[]
: T[P] extends (...args: any) => any
? T[P] | undefined
: T[P] extends object
? RecursivePartial<T[P]>
: T[P];
};
+6
View File
@@ -0,0 +1,6 @@
export function querySelectorAll(
parentNode: ParentNode,
selector: string,
): HTMLElement[] {
return Array.from(parentNode.querySelectorAll(selector));
}
+133
View File
@@ -0,0 +1,133 @@
import type {
DraggableId,
DragStart,
DragUpdate,
DropResult,
DraggableLocation,
Combine,
} from './types';
export interface MessagePreset {
dragHandleUsageInstructions: string;
onDragStart: (start: DragStart) => string;
onDragUpdate: (update: DragUpdate) => string;
onDragEnd: (result: DropResult) => string;
}
const dragHandleUsageInstructions = `
Press space bar to start a drag.
When dragging you can use the arrow keys to move the item around and escape to cancel.
Some screen readers may require you to be in focus mode or to use your pass through key
`;
const position = (index: number): number => index + 1;
// We cannot list what index the Droppable is in automatically as we are not sure how
// the Droppable's have been configured
const onDragStart = (start: DragStart): string => `
You have lifted an item in position ${position(start.source.index)}
`;
const withLocation = (
source: DraggableLocation,
destination: DraggableLocation,
) => {
const isInHomeList: boolean = source.droppableId === destination.droppableId;
const startPosition: number = position(source.index);
const endPosition: number = position(destination.index);
if (isInHomeList) {
return `
You have moved the item from position ${startPosition}
to position ${endPosition}
`;
}
return `
You have moved the item from position ${startPosition}
in list ${source.droppableId}
to list ${destination.droppableId}
in position ${endPosition}
`;
};
const withCombine = (
id: DraggableId,
source: DraggableLocation,
combine: Combine,
): string => {
const inHomeList: boolean = source.droppableId === combine.droppableId;
if (inHomeList) {
return `
The item ${id}
has been combined with ${combine.draggableId}`;
}
return `
The item ${id}
in list ${source.droppableId}
has been combined with ${combine.draggableId}
in list ${combine.droppableId}
`;
};
const onDragUpdate = (update: DragUpdate): string => {
const location: DraggableLocation | null = update.destination;
if (location) {
return withLocation(update.source, location);
}
const combine: Combine | null = update.combine;
if (combine) {
return withCombine(update.draggableId, update.source, combine);
}
return 'You are over an area that cannot be dropped on';
};
const returnedToStart = (source: DraggableLocation): string => `
The item has returned to its starting position
of ${position(source.index)}
`;
const onDragEnd = (result: DropResult): string => {
if (result.reason === 'CANCEL') {
return `
Movement cancelled.
${returnedToStart(result.source)}
`;
}
const location: DraggableLocation | null = result.destination;
const combine: Combine | null = result.combine;
if (location) {
return `
You have dropped the item.
${withLocation(result.source, location)}
`;
}
if (combine) {
return `
You have dropped the item.
${withCombine(result.draggableId, result.source, combine)}
`;
}
return `
The item has been dropped while not over a drop area.
${returnedToStart(result.source)}
`;
};
const preset: MessagePreset = {
dragHandleUsageInstructions,
onDragStart,
onDragUpdate,
onDragEnd,
};
export default preset;
+415
View File
@@ -0,0 +1,415 @@
import type { Position } from 'css-box-model';
import type {
Critical,
DraggableId,
DroppableId,
CompletedDrag,
MovementMode,
Viewport,
DimensionMap,
DropReason,
Published,
} from '../types';
type ActionTypes = Action['type'];
type ExtractAction<TActionType extends ActionTypes> = Extract<
Action,
{ type: TActionType }
>;
export function guard<TActionType extends ActionTypes>(
action: unknown,
predicate: TActionType,
): action is ExtractAction<TActionType> {
return (
action instanceof Object && 'type' in action && action.type === predicate
);
}
export interface BeforeInitialCaptureArgs {
draggableId: DraggableId;
movementMode: MovementMode;
}
export interface BeforeInitialCaptureAction {
type: 'BEFORE_INITIAL_CAPTURE';
payload: BeforeInitialCaptureArgs;
}
export type BeforeInitialCaptureActionCreator = typeof beforeInitialCapture;
export const beforeInitialCapture = (
args: BeforeInitialCaptureArgs,
): BeforeInitialCaptureAction => ({
type: 'BEFORE_INITIAL_CAPTURE',
payload: args,
});
export interface LiftArgs {
// lifting with DraggableId rather than descriptor
// as the descriptor might change after a drop is flushed
id: DraggableId;
clientSelection: Position;
movementMode: MovementMode;
}
export interface LiftAction {
type: 'LIFT';
payload: LiftArgs;
}
export type LiftActionCreator = typeof lift;
export const lift = (args: LiftArgs): LiftAction => ({
type: 'LIFT',
payload: args,
});
export interface InitialPublishArgs {
critical: Critical;
dimensions: DimensionMap;
clientSelection: Position;
viewport: Viewport;
movementMode: MovementMode;
}
export interface InitialPublishAction {
type: 'INITIAL_PUBLISH';
payload: InitialPublishArgs;
}
export type InitialPublishActionCreator = typeof initialPublish;
export const initialPublish = (
args: InitialPublishArgs,
): InitialPublishAction => ({
type: 'INITIAL_PUBLISH',
payload: args,
});
export interface PublishWhileDraggingAction {
type: 'PUBLISH_WHILE_DRAGGING';
payload: Published;
}
export type PublishWhileDraggingArgs = Published;
export type PublishWhileDraggingActionCreator = typeof publishWhileDragging;
export const publishWhileDragging = (
args: PublishWhileDraggingArgs,
): PublishWhileDraggingAction => ({
type: 'PUBLISH_WHILE_DRAGGING',
payload: args,
});
export interface CollectionStartingAction {
type: 'COLLECTION_STARTING';
payload: null;
}
export type CollectionStartingActionCreator = typeof collectionStarting;
export const collectionStarting = (): CollectionStartingAction => ({
type: 'COLLECTION_STARTING',
payload: null,
});
export interface UpdateDroppableScrollArgs {
id: DroppableId;
newScroll: Position;
}
export interface UpdateDroppableScrollAction {
type: 'UPDATE_DROPPABLE_SCROLL';
payload: UpdateDroppableScrollArgs;
}
export type UpdateDroppableScrollActionCreator = typeof updateDroppableScroll;
export const updateDroppableScroll = (
args: UpdateDroppableScrollArgs,
): UpdateDroppableScrollAction => ({
type: 'UPDATE_DROPPABLE_SCROLL',
payload: args,
});
export interface UpdateDroppableIsEnabledArgs {
id: DroppableId;
isEnabled: boolean;
}
export interface UpdateDroppableIsEnabledAction {
type: 'UPDATE_DROPPABLE_IS_ENABLED';
payload: UpdateDroppableIsEnabledArgs;
}
export type UpdateDroppableIsEnabledActionCreator =
typeof updateDroppableIsEnabled;
export const updateDroppableIsEnabled = (
args: UpdateDroppableIsEnabledArgs,
): UpdateDroppableIsEnabledAction => ({
type: 'UPDATE_DROPPABLE_IS_ENABLED',
payload: args,
});
export interface UpdateDroppableIsCombineEnabledArgs {
id: DroppableId;
isCombineEnabled: boolean;
}
export interface UpdateDroppableIsCombineEnabledAction {
type: 'UPDATE_DROPPABLE_IS_COMBINE_ENABLED';
payload: UpdateDroppableIsCombineEnabledArgs;
}
export type UpdateDroppableIsCombineEnabledActionCreator =
typeof updateDroppableIsCombineEnabled;
export const updateDroppableIsCombineEnabled = (
args: UpdateDroppableIsCombineEnabledArgs,
): UpdateDroppableIsCombineEnabledAction => ({
type: 'UPDATE_DROPPABLE_IS_COMBINE_ENABLED',
payload: args,
});
export interface MoveArgs {
client: Position;
}
export interface MoveAction {
type: 'MOVE';
payload: MoveArgs;
}
export type MoveActionCreator = typeof move;
export const move = (args: MoveArgs): MoveAction => ({
type: 'MOVE',
payload: args,
});
interface MoveByWindowScrollArgs {
newScroll: Position;
}
export interface MoveByWindowScrollAction {
type: 'MOVE_BY_WINDOW_SCROLL';
payload: MoveByWindowScrollArgs;
}
export type MoveByWindowScrollActionCreator = typeof moveByWindowScroll;
export const moveByWindowScroll = (
args: MoveByWindowScrollArgs,
): MoveByWindowScrollAction => ({
type: 'MOVE_BY_WINDOW_SCROLL',
payload: args,
});
export interface UpdateViewportMaxScrollArgs {
maxScroll: Position;
}
export interface UpdateViewportMaxScrollAction {
type: 'UPDATE_VIEWPORT_MAX_SCROLL';
payload: UpdateViewportMaxScrollArgs;
}
export type UpdateViewportMaxScrollActionCreator =
typeof updateViewportMaxScroll;
export const updateViewportMaxScroll = (
args: UpdateViewportMaxScrollArgs,
): UpdateViewportMaxScrollAction => ({
type: 'UPDATE_VIEWPORT_MAX_SCROLL',
payload: args,
});
export interface MoveUpAction {
type: 'MOVE_UP';
payload: null;
}
export type MoveUpActionCreator = typeof moveUp;
export const moveUp = (): MoveUpAction => ({
type: 'MOVE_UP',
payload: null,
});
export interface MoveDownAction {
type: 'MOVE_DOWN';
payload: null;
}
export type MoveDownActionCreator = typeof moveDown;
export const moveDown = (): MoveDownAction => ({
type: 'MOVE_DOWN',
payload: null,
});
export interface MoveRightAction {
type: 'MOVE_RIGHT';
payload: null;
}
export type MoveRightActionCreator = typeof moveRight;
export const moveRight = (): MoveRightAction => ({
type: 'MOVE_RIGHT',
payload: null,
});
export interface MoveLeftAction {
type: 'MOVE_LEFT';
payload: null;
}
export type MoveLeftActionCreator = typeof moveLeft;
export const moveLeft = (): MoveLeftAction => ({
type: 'MOVE_LEFT',
payload: null,
});
export interface FlushAction {
type: 'FLUSH';
payload: null;
}
export type FlushActionCreator = typeof flush;
export const flush = (): FlushAction => ({
type: 'FLUSH',
payload: null,
});
export interface AnimateDropArgs {
completed: CompletedDrag;
newHomeClientOffset: Position;
dropDuration: number;
}
export interface DropAnimateAction {
type: 'DROP_ANIMATE';
payload: AnimateDropArgs;
}
export type AnimateDropActionCreator = typeof animateDrop;
export const animateDrop = (args: AnimateDropArgs): DropAnimateAction => ({
type: 'DROP_ANIMATE',
payload: args,
});
export interface DropCompleteArgs {
completed: CompletedDrag;
}
export interface DropCompleteAction {
type: 'DROP_COMPLETE';
payload: DropCompleteArgs;
}
export type CompleteDropActionCreator = typeof completeDrop;
export const completeDrop = (args: DropCompleteArgs): DropCompleteAction => ({
type: 'DROP_COMPLETE',
payload: args,
});
interface DropArgs {
reason: DropReason;
}
export interface DropAction {
type: 'DROP';
payload: DropArgs;
}
export type DropActionCreator = typeof drop;
export const drop = (args: DropArgs): DropAction => ({
type: 'DROP',
payload: args,
});
export type CancelActionCreator = typeof cancel;
export const cancel = () => drop({ reason: 'CANCEL' });
export interface DropPendingAction {
type: 'DROP_PENDING';
payload: DropArgs;
}
export type DropPendingActionCreator = typeof dropPending;
export const dropPending = (args: DropArgs): DropPendingAction => ({
type: 'DROP_PENDING',
payload: args,
});
export interface DropAnimationFinishedAction {
type: 'DROP_ANIMATION_FINISHED';
payload: null;
}
export type DropAnimationFinishedActionCreator = typeof dropAnimationFinished;
export const dropAnimationFinished = (): DropAnimationFinishedAction => ({
type: 'DROP_ANIMATION_FINISHED',
payload: null,
});
export type Action =
| BeforeInitialCaptureAction
| LiftAction
| InitialPublishAction
| PublishWhileDraggingAction
| CollectionStartingAction
| UpdateDroppableScrollAction
| UpdateDroppableIsEnabledAction
| UpdateDroppableIsCombineEnabledAction
| MoveByWindowScrollAction // | PostJumpScrollAction
// | PostSnapDestinationChangeAction
| UpdateViewportMaxScrollAction
| MoveAction
| MoveUpAction
| MoveDownAction
| MoveRightAction
| MoveLeftAction
| DropPendingAction
| DropAction
| DropAnimateAction
| DropAnimationFinishedAction
| DropCompleteAction
| FlushAction;
export type ActionCreator =
| BeforeInitialCaptureActionCreator
| LiftActionCreator
| InitialPublishActionCreator
| PublishWhileDraggingActionCreator
| CollectionStartingActionCreator
| UpdateDroppableScrollActionCreator
| UpdateDroppableIsEnabledActionCreator
| UpdateDroppableIsCombineEnabledActionCreator
| MoveActionCreator
| MoveByWindowScrollActionCreator
| UpdateViewportMaxScrollActionCreator
| MoveUpActionCreator
| MoveDownActionCreator
| MoveRightActionCreator
| MoveLeftActionCreator
| FlushActionCreator
| AnimateDropActionCreator
| CompleteDropActionCreator
| DropActionCreator
| CancelActionCreator
| DropPendingActionCreator
| DropAnimationFinishedActionCreator;
@@ -0,0 +1,7 @@
import type { State, DraggingState } from '../../types';
export interface AutoScroller {
start: (state: DraggingState) => void;
stop: () => void;
scroll: (state: State) => void;
}
+159
View File
@@ -0,0 +1,159 @@
import type { Position } from 'css-box-model';
import { add, apply, isEqual, origin } from '../position';
import type { DroppableDimension, Viewport, Scrollable } from '../../types';
interface CanPartiallyScrollArgs {
max: Position;
current: Position;
change: Position;
}
const smallestSigned = apply((value: number) => {
if (value === 0) {
return 0;
}
return value > 0 ? 1 : -1;
});
interface GetRemainderArgs {
current: Position;
max: Position;
change: Position;
}
// We need to figure out how much of the movement
// cannot be done with a scroll
export const getOverlap = (() => {
const getRemainder = (target: number, max: number): number => {
if (target < 0) {
return target;
}
if (target > max) {
return target - max;
}
return 0;
};
return ({ current, max, change }: GetRemainderArgs): Position | null => {
const targetScroll: Position = add(current, change);
const overlap: Position = {
x: getRemainder(targetScroll.x, max.x),
y: getRemainder(targetScroll.y, max.y),
};
if (isEqual(overlap, origin)) {
return null;
}
return overlap;
};
})();
export const canPartiallyScroll = ({
max: rawMax,
current,
change,
}: CanPartiallyScrollArgs): boolean => {
// It is possible for the max scroll to be greater than the current scroll
// when there are scrollbars on the cross axis. We adjust for this by
// increasing the max scroll point if needed
// This will allow movements backwards even if the current scroll is greater than the max scroll
const max: Position = {
x: Math.max(current.x, rawMax.x),
y: Math.max(current.y, rawMax.y),
};
// Only need to be able to move the smallest amount in the desired direction
const smallestChange: Position = smallestSigned(change);
const overlap: Position | null = getOverlap({
max,
current,
change: smallestChange,
});
// no overlap at all - we can move there!
if (!overlap) {
return true;
}
// if there was an x value, but there is no x overlap - then we can scroll on the x!
if (smallestChange.x !== 0 && overlap.x === 0) {
return true;
}
// if there was an y value, but there is no y overlap - then we can scroll on the y!
if (smallestChange.y !== 0 && overlap.y === 0) {
return true;
}
return false;
};
export const canScrollWindow = (
viewport: Viewport,
change: Position,
): boolean =>
canPartiallyScroll({
current: viewport.scroll.current,
max: viewport.scroll.max,
change,
});
export const getWindowOverlap = (
viewport: Viewport,
change: Position,
): Position | null => {
if (!canScrollWindow(viewport, change)) {
return null;
}
const max: Position = viewport.scroll.max;
const current: Position = viewport.scroll.current;
return getOverlap({
current,
max,
change,
});
};
export const canScrollDroppable = (
droppable: DroppableDimension,
change: Position,
): boolean => {
const frame: Scrollable | null = droppable.frame;
// Cannot scroll when there is no scrollable
if (!frame) {
return false;
}
return canPartiallyScroll({
current: frame.scroll.current,
max: frame.scroll.max,
change,
});
};
export const getDroppableOverlap = (
droppable: DroppableDimension,
change: Position,
): Position | null => {
const frame: Scrollable | null = droppable.frame;
if (!frame) {
return null;
}
if (!canScrollDroppable(droppable, change)) {
return null;
}
return getOverlap({
current: frame.scroll.current,
max: frame.scroll.max,
change,
});
};
@@ -0,0 +1,50 @@
import { RecursivePartial } from '../../../lib-types';
/**
* Customize autoScroller behavior
*/
export interface AutoScrollerOptions {
/**
* Percentage distance from edge of container at which to start auto scrolling.
* ex. 0.1 or 0.9
*/
startFromPercentage: number;
/**
* Percentage distance from edge of container at which max scroll speed is achieved.
* Should be less than startFromPercentage
*/
maxScrollAtPercentage: number;
/**
* Maximum pixels to scroll per frame
*/
maxPixelScroll: number;
/**
* A function used to ease a percentage value
* A simple linear function would be: (percentage) => percentage;
* percentage is between 0 and 1
* result must be between 0 and 1
*/
ease: (percentage: number) => number;
durationDampening: {
/**
* How long to dampen the speed of an auto scroll from the start of a drag in milliseconds
*/
stopDampeningAt: number;
/**
* When to start accelerating the reduction of duration dampening in milliseconds
*/
accelerateAt: number;
};
/**
* Whether or not autoscroll should be turned off entirely
*/
disabled: boolean;
}
export type PartialAutoScrollerOptions = RecursivePartial<AutoScrollerOptions>;
@@ -0,0 +1,14 @@
import { AutoScrollerOptions } from './auto-scroller-options-types';
// default autoScroll configuration options
export const defaultAutoScrollerOptions: AutoScrollerOptions = {
startFromPercentage: 0.25,
maxScrollAtPercentage: 0.05,
maxPixelScroll: 28,
ease: (percentage: number): number => percentage ** 2,
durationDampening: {
stopDampeningAt: 1200,
accelerateAt: 360,
},
disabled: false,
};
@@ -0,0 +1,76 @@
import memoizeOne from 'memoize-one';
import type { Position } from 'css-box-model';
import type {
DroppableDimension,
DroppableDimensionMap,
DroppableId,
} from '../../../types';
import { invariant } from '../../../invariant';
import isPositionInFrame from '../../visibility/is-position-in-frame';
import { toDroppableList } from '../../dimension-structures';
const getScrollableDroppables = memoizeOne(
(droppables: DroppableDimensionMap): DroppableDimension[] =>
toDroppableList(droppables).filter(
(droppable: DroppableDimension): boolean => {
// exclude disabled droppables
if (!droppable.isEnabled) {
return false;
}
// only want droppables that are scrollable
if (!droppable.frame) {
return false;
}
return true;
},
),
);
const getScrollableDroppableOver = (
target: Position,
droppables: DroppableDimensionMap,
): DroppableDimension | null => {
const maybe =
getScrollableDroppables(droppables).find(
(droppable: DroppableDimension): boolean => {
invariant(droppable.frame, 'Invalid result');
return isPositionInFrame(droppable.frame.pageMarginBox)(target);
},
) || null;
return maybe;
};
interface Api {
center: Position;
destination: DroppableId | null;
droppables: DroppableDimensionMap;
}
export default ({
center,
destination,
droppables,
}: Api): DroppableDimension | null => {
// We need to scroll the best droppable frame we can so that the
// placeholder buffer logic works correctly
if (destination) {
const dimension: DroppableDimension = droppables[destination];
if (!dimension.frame) {
return null;
}
return dimension;
}
// 2. If we are not over a droppable - are we over a droppable frame?
const dimension: DroppableDimension | null = getScrollableDroppableOver(
center,
droppables,
);
return dimension;
};
@@ -0,0 +1,42 @@
import type { Position, Rect } from 'css-box-model';
import type { Scrollable, DroppableDimension } from '../../../types';
import getScroll from './get-scroll';
import { canScrollDroppable } from '../can-scroll';
import { AutoScrollerOptions } from './auto-scroller-options-types';
interface Args {
droppable: DroppableDimension;
subject: Rect;
center: Position;
dragStartTime: number;
shouldUseTimeDampening: boolean;
getAutoScrollerOptions: () => AutoScrollerOptions;
}
export default ({
droppable,
subject,
center,
dragStartTime,
shouldUseTimeDampening,
getAutoScrollerOptions,
}: Args): Position | null => {
// We know this has a closestScrollable
const frame: Scrollable | null = droppable.frame;
// this should never happen - just being safe
if (!frame) {
return null;
}
const scroll: Position | null = getScroll({
dragStartTime,
container: frame.pageMarginBox,
subject,
center,
shouldUseTimeDampening,
getAutoScrollerOptions,
});
return scroll && canScrollDroppable(droppable, scroll) ? scroll : null;
};
@@ -0,0 +1,24 @@
import { warning } from '../../../dev-warning';
interface Args {
startOfRange: number;
endOfRange: number;
current: number;
}
export default ({ startOfRange, endOfRange, current }: Args): number => {
const range: number = endOfRange - startOfRange;
if (range === 0) {
warning(`
Detected distance range of 0 in the fluid auto scroller
This is unexpected and would cause a divide by 0 issue.
Not allowing an auto scroll
`);
return 0;
}
const currentInRange: number = current - startOfRange;
const percentage: number = currentInRange / range;
return percentage;
};
@@ -0,0 +1,33 @@
import type { Rect, Position } from 'css-box-model';
interface Args {
container: Rect;
subject: Rect;
proposedScroll: Position;
}
export default ({
container,
subject,
proposedScroll,
}: Args): Position | null => {
const isTooBigVertically: boolean = subject.height > container.height;
const isTooBigHorizontally: boolean = subject.width > container.width;
// not too big on any axis
if (!isTooBigHorizontally && !isTooBigVertically) {
return proposedScroll;
}
// too big on both axis
if (isTooBigHorizontally && isTooBigVertically) {
return null;
}
// Only too big on one axis
// Exclude the axis that we cannot scroll on
return {
x: isTooBigHorizontally ? 0 : proposedScroll.x,
y: isTooBigVertically ? 0 : proposedScroll.y,
};
};
@@ -0,0 +1,44 @@
import getPercentage from '../../get-percentage';
import { AutoScrollerOptions } from '../../auto-scroller-options-types';
import minScroll from './min-scroll';
export default (
proposedScroll: number,
dragStartTime: number,
getAutoScrollerOptions: () => AutoScrollerOptions,
): number => {
const autoScrollerOptions = getAutoScrollerOptions();
const accelerateAt: number =
autoScrollerOptions.durationDampening.accelerateAt;
const stopAt: number = autoScrollerOptions.durationDampening.stopDampeningAt;
const startOfRange: number = dragStartTime;
const endOfRange: number = stopAt;
const now: number = Date.now();
const runTime: number = now - startOfRange;
// we have finished the time dampening period
if (runTime >= stopAt) {
return proposedScroll;
}
// Up to this point we know there is a proposed scroll
// but we have not reached our accelerate point
// Return the minimum amount of scroll
if (runTime < accelerateAt) {
return minScroll;
}
const betweenAccelerateAtAndStopAtPercentage: number = getPercentage({
startOfRange: accelerateAt,
endOfRange,
current: runTime,
});
const scroll: number =
proposedScroll *
autoScrollerOptions.ease(betweenAccelerateAtAndStopAtPercentage);
return Math.ceil(scroll);
};
@@ -0,0 +1,32 @@
import type { Rect } from 'css-box-model';
import { AutoScrollerOptions } from '../../auto-scroller-options-types';
import type { Axis } from '../../../../../types';
import { defaultAutoScrollerOptions } from '../../config';
// all in pixels
export interface DistanceThresholds {
startScrollingFrom: number;
maxScrollValueAt: number;
}
// converts the percentages in the config into actual pixel values
export default (
container: Rect,
axis: Axis,
getAutoScrollerOptions: () => AutoScrollerOptions = () =>
defaultAutoScrollerOptions,
): DistanceThresholds => {
const autoScrollerOptions = getAutoScrollerOptions();
const startScrollingFrom: number =
container[axis.size] * autoScrollerOptions.startFromPercentage;
const maxScrollValueAt: number =
container[axis.size] * autoScrollerOptions.maxScrollAtPercentage;
const thresholds: DistanceThresholds = {
startScrollingFrom,
maxScrollValueAt,
};
return thresholds;
};
@@ -0,0 +1,66 @@
import type { DistanceThresholds } from './get-distance-thresholds';
import getPercentage from '../../get-percentage';
import { AutoScrollerOptions } from '../../auto-scroller-options-types';
import minScroll from './min-scroll';
import { defaultAutoScrollerOptions } from '../../config';
export default (
distanceToEdge: number,
thresholds: DistanceThresholds,
getAutoScrollerOptions: () => AutoScrollerOptions = () =>
defaultAutoScrollerOptions,
): number => {
const autoScrollerOptions = getAutoScrollerOptions();
/*
// This function only looks at the distance to one edge
// Example: looking at bottom edge
|----------------------------------|
| |
| |
| |
| |
| | => no scroll in this range
| |
| |
| startScrollingFrom (eg 100px) |
| |
| | => increased scroll value the closer to maxScrollValueAt
| maxScrollValueAt (eg 10px) |
| | => max scroll value in this range
|----------------------------------|
*/
// too far away to auto scroll
if (distanceToEdge > thresholds.startScrollingFrom) {
return 0;
}
// use max speed when on or over boundary
if (distanceToEdge <= thresholds.maxScrollValueAt) {
return autoScrollerOptions.maxPixelScroll;
}
// when just going on the boundary return the minimum integer
if (distanceToEdge === thresholds.startScrollingFrom) {
return minScroll;
}
// to get the % past startScrollingFrom we will calculate
// the % the value is from maxScrollValueAt and then invert it
const percentageFromMaxScrollValueAt: number = getPercentage({
startOfRange: thresholds.maxScrollValueAt,
endOfRange: thresholds.startScrollingFrom,
current: distanceToEdge,
});
const percentageFromStartScrollingFrom: number =
1 - percentageFromMaxScrollValueAt;
const scroll: number =
autoScrollerOptions.maxPixelScroll *
autoScrollerOptions.ease(percentageFromStartScrollingFrom);
// scroll will always be a positive integer
return Math.ceil(scroll);
};
@@ -0,0 +1,48 @@
import type { DistanceThresholds } from './get-distance-thresholds';
import { AutoScrollerOptions } from '../../auto-scroller-options-types';
import getValueFromDistance from './get-value-from-distance';
import dampenValueByTime from './dampen-value-by-time';
import minScroll from './min-scroll';
interface Args {
distanceToEdge: number;
thresholds: DistanceThresholds;
dragStartTime: number;
shouldUseTimeDampening: boolean;
getAutoScrollerOptions: () => AutoScrollerOptions;
}
export default ({
distanceToEdge,
thresholds,
dragStartTime,
shouldUseTimeDampening,
getAutoScrollerOptions,
}: Args): number => {
const scroll: number = getValueFromDistance(
distanceToEdge,
thresholds,
getAutoScrollerOptions,
);
// not enough distance to trigger a minimum scroll
// we can bail here
if (scroll === 0) {
return 0;
}
// Dampen an auto scroll speed based on duration of drag
if (!shouldUseTimeDampening) {
return scroll;
}
// Once we know an auto scroll should occur based on distance,
// we must let at least 1px through to trigger a scroll event an
// another auto scroll call
return Math.max(
dampenValueByTime(scroll, dragStartTime, getAutoScrollerOptions),
minScroll,
);
};
@@ -0,0 +1,53 @@
import type { Rect, Spacing } from 'css-box-model';
import getDistanceThresholds from './get-distance-thresholds';
import { AutoScrollerOptions } from '../../auto-scroller-options-types';
import type { DistanceThresholds } from './get-distance-thresholds';
import type { Axis } from '../../../../../types';
import getValue from './get-value';
interface GetOnAxisArgs {
container: Rect;
distanceToEdges: Spacing;
dragStartTime: number;
axis: Axis;
shouldUseTimeDampening: boolean;
getAutoScrollerOptions: () => AutoScrollerOptions;
}
export default ({
container,
distanceToEdges,
dragStartTime,
axis,
shouldUseTimeDampening,
getAutoScrollerOptions,
}: GetOnAxisArgs): number => {
const thresholds: DistanceThresholds = getDistanceThresholds(
container,
axis,
getAutoScrollerOptions,
);
const isCloserToEnd: boolean =
distanceToEdges[axis.end] < distanceToEdges[axis.start];
if (isCloserToEnd) {
return getValue({
distanceToEdge: distanceToEdges[axis.end],
thresholds,
dragStartTime,
shouldUseTimeDampening,
getAutoScrollerOptions,
});
}
return (
-1 *
getValue({
distanceToEdge: distanceToEdges[axis.start],
thresholds,
dragStartTime,
shouldUseTimeDampening,
getAutoScrollerOptions,
})
);
};
@@ -0,0 +1,2 @@
// A scroll event will only be triggered when there is a value of at least 1px change
export default 1;
@@ -0,0 +1,80 @@
import type { Position, Rect, Spacing } from 'css-box-model';
import { apply, isEqual, origin } from '../../../position';
import getScrollOnAxis from './get-scroll-on-axis';
import adjustForSizeLimits from './adjust-for-size-limits';
import { horizontal, vertical } from '../../../axis';
import { AutoScrollerOptions } from '../auto-scroller-options-types';
// will replace -0 and replace with +0
const clean = apply((value: number) => (value === 0 ? 0 : value));
interface Args {
dragStartTime: number;
container: Rect;
subject: Rect;
center: Position;
shouldUseTimeDampening: boolean;
getAutoScrollerOptions: () => AutoScrollerOptions;
}
export default ({
dragStartTime,
container,
subject,
center,
shouldUseTimeDampening,
getAutoScrollerOptions,
}: Args): Position | null => {
// get distance to each edge
const distanceToEdges: Spacing = {
top: center.y - container.top,
right: container.right - center.x,
bottom: container.bottom - center.y,
left: center.x - container.left,
};
// 1. Figure out which x,y values are the best target
// 2. Can the container scroll in that direction at all?
// If no for both directions, then return null
// 3. Is the center close enough to a edge to start a drag?
// 4. Based on the distance, calculate the speed at which a scroll should occur
// The lower distance value the faster the scroll should be.
// Maximum speed value should be hit before the distance is 0
// Negative values to not continue to increase the speed
const y: number = getScrollOnAxis({
container,
distanceToEdges,
dragStartTime,
axis: vertical,
shouldUseTimeDampening,
getAutoScrollerOptions,
});
const x: number = getScrollOnAxis({
container,
distanceToEdges,
dragStartTime,
axis: horizontal,
shouldUseTimeDampening,
getAutoScrollerOptions,
});
const required: Position = clean({ x, y });
// nothing required
if (isEqual(required, origin)) {
return null;
}
// need to not scroll in a direction that we are too big to scroll in
const limited: Position | null = adjustForSizeLimits({
container,
subject,
proposedScroll: required,
});
if (!limited) {
return null;
}
return isEqual(limited, origin) ? null : limited;
};
@@ -0,0 +1,34 @@
import type { Position, Rect } from 'css-box-model';
import type { Viewport } from '../../../types';
import getScroll from './get-scroll';
import { canScrollWindow } from '../can-scroll';
import { AutoScrollerOptions } from './auto-scroller-options-types';
interface Args {
viewport: Viewport;
subject: Rect;
center: Position;
dragStartTime: number;
shouldUseTimeDampening: boolean;
getAutoScrollerOptions: () => AutoScrollerOptions;
}
export default ({
viewport,
subject,
center,
dragStartTime,
shouldUseTimeDampening,
getAutoScrollerOptions,
}: Args): Position | null => {
const scroll: Position | null = getScroll({
dragStartTime,
container: viewport.frame,
subject,
center,
shouldUseTimeDampening,
getAutoScrollerOptions,
});
return scroll && canScrollWindow(viewport, scroll) ? scroll : null;
};
@@ -0,0 +1,95 @@
import rafSchd from 'raf-schd';
import type { Position } from 'css-box-model';
import type { DraggingState, DroppableId } from '../../../types';
import scroll from './scroll';
import { invariant } from '../../../invariant';
import * as timings from '../../../debug/timings';
import { AutoScrollerOptions } from './auto-scroller-options-types';
import { defaultAutoScrollerOptions } from './config';
export interface PublicArgs {
scrollWindow: (change: Position) => void;
scrollDroppable: (id: DroppableId, change: Position) => void;
getAutoScrollerOptions?: () => AutoScrollerOptions;
}
export interface FluidScroller {
scroll: (state: DraggingState) => void;
start: (state: DraggingState) => void;
stop: () => void;
}
interface WhileDragging {
dragStartTime: number;
shouldUseTimeDampening: boolean;
}
export default ({
scrollWindow,
scrollDroppable,
getAutoScrollerOptions = () => defaultAutoScrollerOptions,
}: PublicArgs): FluidScroller => {
const scheduleWindowScroll = rafSchd(scrollWindow);
const scheduleDroppableScroll = rafSchd(scrollDroppable);
let dragging: WhileDragging | null = null;
const tryScroll = (state: DraggingState): void => {
invariant(dragging, 'Cannot fluid scroll if not dragging');
const { shouldUseTimeDampening, dragStartTime } = dragging;
scroll({
state,
scrollWindow: scheduleWindowScroll,
scrollDroppable: scheduleDroppableScroll,
dragStartTime,
shouldUseTimeDampening,
getAutoScrollerOptions,
});
};
const start = (state: DraggingState) => {
timings.start('starting fluid scroller');
invariant(!dragging, 'Cannot start auto scrolling when already started');
const dragStartTime: number = Date.now();
let wasScrollNeeded = false;
const fakeScrollCallback = () => {
wasScrollNeeded = true;
};
scroll({
state,
dragStartTime: 0,
shouldUseTimeDampening: false,
scrollWindow: fakeScrollCallback,
scrollDroppable: fakeScrollCallback,
getAutoScrollerOptions,
});
dragging = {
dragStartTime,
shouldUseTimeDampening: wasScrollNeeded,
};
timings.finish('starting fluid scroller');
// we know an auto scroll is needed - let's do it!
if (wasScrollNeeded) {
tryScroll(state);
}
};
const stop = () => {
// can be called defensively
if (!dragging) {
return;
}
scheduleWindowScroll.cancel();
scheduleDroppableScroll.cancel();
dragging = null;
};
return {
start,
stop,
scroll: tryScroll,
};
};
@@ -0,0 +1,76 @@
import type { Position, Rect } from 'css-box-model';
import type {
DraggingState,
DroppableId,
DraggableDimension,
DroppableDimension,
Viewport,
} from '../../../types';
import getBestScrollableDroppable from './get-best-scrollable-droppable';
import whatIsDraggedOver from '../../droppable/what-is-dragged-over';
import getWindowScrollChange from './get-window-scroll-change';
import getDroppableScrollChange from './get-droppable-scroll-change';
import { AutoScrollerOptions } from './auto-scroller-options-types';
interface Args {
state: DraggingState;
dragStartTime: number;
shouldUseTimeDampening: boolean;
scrollWindow: (scroll: Position) => void;
scrollDroppable: (id: DroppableId, scroll: Position) => void;
getAutoScrollerOptions: () => AutoScrollerOptions;
}
export default ({
state,
dragStartTime,
shouldUseTimeDampening,
scrollWindow,
scrollDroppable,
getAutoScrollerOptions,
}: Args): void => {
const center: Position = state.current.page.borderBoxCenter;
const draggable: DraggableDimension =
state.dimensions.draggables[state.critical.draggable.id];
const subject: Rect = draggable.page.marginBox;
// 1. Can we scroll the viewport?
if (state.isWindowScrollAllowed) {
const viewport: Viewport = state.viewport;
const change: Position | null = getWindowScrollChange({
dragStartTime,
viewport,
subject,
center,
shouldUseTimeDampening,
getAutoScrollerOptions,
});
if (change) {
scrollWindow(change);
return;
}
}
const droppable: DroppableDimension | null = getBestScrollableDroppable({
center,
destination: whatIsDraggedOver(state.impact),
droppables: state.dimensions.droppables,
});
if (!droppable) {
return;
}
const change: Position | null = getDroppableScrollChange({
dragStartTime,
droppable,
subject,
center,
shouldUseTimeDampening,
getAutoScrollerOptions,
});
if (change) {
scrollDroppable(droppable.descriptor.id, change);
}
};
+64
View File
@@ -0,0 +1,64 @@
import type { Position } from 'css-box-model';
import createFluidScroller from './fluid-scroller';
import type { FluidScroller } from './fluid-scroller';
import createJumpScroller from './jump-scroller';
import type { JumpScroller } from './jump-scroller';
import type { AutoScroller } from './auto-scroller-types';
import type { DroppableId, State } from '../../types';
import type { MoveArgs } from '../action-creators';
import { AutoScrollerOptions } from './fluid-scroller/auto-scroller-options-types';
export interface Args {
scrollWindow: (offset: Position) => void;
scrollDroppable: (id: DroppableId, change: Position) => void;
move: (args: MoveArgs) => unknown;
getAutoScrollerOptions: () => AutoScrollerOptions;
}
export default ({
scrollDroppable,
scrollWindow,
move,
getAutoScrollerOptions,
}: Args): AutoScroller => {
const fluidScroller: FluidScroller = createFluidScroller({
scrollWindow,
scrollDroppable,
getAutoScrollerOptions,
});
const jumpScroll: JumpScroller = createJumpScroller({
move,
scrollWindow,
scrollDroppable,
});
const scroll = (state: State) => {
const autoScrollerOptions = getAutoScrollerOptions();
// Only allowing auto scrolling in the DRAGGING phase
// and when autoScroll is not disabled
if (autoScrollerOptions.disabled || state.phase !== 'DRAGGING') {
return;
}
if (state.movementMode === 'FLUID') {
fluidScroller.scroll(state);
return;
}
if (!state.scrollJumpRequest) {
return;
}
jumpScroll(state);
};
const scroller: AutoScroller = {
scroll,
start: fluidScroller.start,
stop: fluidScroller.stop,
};
return scroller;
};
+138
View File
@@ -0,0 +1,138 @@
import type { Position } from 'css-box-model';
import { invariant } from '../../invariant';
import { add, subtract } from '../position';
import {
canScrollWindow,
canScrollDroppable,
getWindowOverlap,
getDroppableOverlap,
} from './can-scroll';
import whatIsDraggedOver from '../droppable/what-is-dragged-over';
import type { MoveArgs } from '../action-creators';
import type {
DroppableDimension,
Viewport,
DraggingState,
DroppableId,
} from '../../types';
interface Args {
scrollDroppable: (id: DroppableId, change: Position) => void;
scrollWindow: (offset: Position) => void;
move: (args: MoveArgs) => unknown;
}
export type JumpScroller = (state: DraggingState) => void;
type Remainder = Position;
export default ({
move,
scrollDroppable,
scrollWindow,
}: Args): JumpScroller => {
const moveByOffset = (state: DraggingState, offset: Position) => {
const client: Position = add(state.current.client.selection, offset);
move({ client });
};
const scrollDroppableAsMuchAsItCan = (
droppable: DroppableDimension,
change: Position,
): Remainder | null => {
// Droppable cannot absorb any of the scroll
if (!canScrollDroppable(droppable, change)) {
return change;
}
const overlap: Position | null = getDroppableOverlap(droppable, change);
// Droppable can absorb the entire change
if (!overlap) {
scrollDroppable(droppable.descriptor.id, change);
return null;
}
// Droppable can only absorb a part of the change
const whatTheDroppableCanScroll: Position = subtract(change, overlap);
scrollDroppable(droppable.descriptor.id, whatTheDroppableCanScroll);
const remainder: Position = subtract(change, whatTheDroppableCanScroll);
return remainder;
};
const scrollWindowAsMuchAsItCan = (
isWindowScrollAllowed: boolean,
viewport: Viewport,
change: Position,
): Position | null => {
if (!isWindowScrollAllowed) {
return change;
}
if (!canScrollWindow(viewport, change)) {
// window cannot absorb any of the scroll
return change;
}
const overlap: Position | null = getWindowOverlap(viewport, change);
// window can absorb entire scroll
if (!overlap) {
scrollWindow(change);
return null;
}
// window can only absorb a part of the scroll
const whatTheWindowCanScroll: Position = subtract(change, overlap);
scrollWindow(whatTheWindowCanScroll);
const remainder: Position = subtract(change, whatTheWindowCanScroll);
return remainder;
};
const jumpScroller: JumpScroller = (state: DraggingState) => {
const request: Position | null = state.scrollJumpRequest;
if (!request) {
return;
}
const destination: DroppableId | null = whatIsDraggedOver(state.impact);
invariant(
destination,
'Cannot perform a jump scroll when there is no destination',
);
// 1. We scroll the droppable first if we can to avoid the draggable
// leaving the list
const droppableRemainder: Position | null = scrollDroppableAsMuchAsItCan(
state.dimensions.droppables[destination],
request,
);
// droppable absorbed the entire scroll
if (!droppableRemainder) {
return;
}
const viewport: Viewport = state.viewport;
const windowRemainder: Position | null = scrollWindowAsMuchAsItCan(
state.isWindowScrollAllowed,
viewport,
droppableRemainder,
);
// window could absorb all the droppable remainder
if (!windowRemainder) {
return;
}
// The entire scroll could not be absorbed by the droppable and window
// so we manually move whatever is left
moveByOffset(state, windowRemainder);
};
return jumpScroller;
};
+25
View File
@@ -0,0 +1,25 @@
import type { HorizontalAxis, VerticalAxis } from '../types';
export const vertical: VerticalAxis = {
direction: 'vertical',
line: 'y',
crossAxisLine: 'x',
start: 'top',
end: 'bottom',
size: 'height',
crossAxisStart: 'left',
crossAxisEnd: 'right',
crossAxisSize: 'width',
};
export const horizontal: HorizontalAxis = {
direction: 'horizontal',
line: 'x',
crossAxisLine: 'y',
start: 'left',
end: 'right',
size: 'width',
crossAxisStart: 'top',
crossAxisEnd: 'bottom',
crossAxisSize: 'height',
};
@@ -0,0 +1,135 @@
import type {
DraggableDimension,
DroppableDimension,
DragImpact,
DisplacementGroups,
Viewport,
DisplacedBy,
} from '../../types';
import removeDraggableFromList from '../remove-draggable-from-list';
import isHomeOf from '../droppable/is-home-of';
import { emptyGroups } from '../no-impact';
import getDisplacementGroups from '../get-displacement-groups';
interface Args {
draggable: DraggableDimension;
insideDestination: DraggableDimension[];
destination: DroppableDimension;
viewport: Viewport;
displacedBy: DisplacedBy;
last: DisplacementGroups;
index: number | null;
forceShouldAnimate?: boolean;
}
function getIndexOfLastItem(
draggables: DraggableDimension[],
options: {
inHomeList: boolean;
},
): number {
if (!draggables.length) {
return 0;
}
const indexOfLastItem: number =
draggables[draggables.length - 1].descriptor.index;
// When in a foreign list there will be an additional one item in the list
return options.inHomeList ? indexOfLastItem : indexOfLastItem + 1;
}
interface GoAtEndArgs {
insideDestination: DraggableDimension[];
inHomeList: boolean;
displacedBy: DisplacedBy;
destination: DroppableDimension;
}
function goAtEnd({
insideDestination,
inHomeList,
displacedBy,
destination,
}: GoAtEndArgs): DragImpact {
const newIndex: number = getIndexOfLastItem(insideDestination, {
inHomeList,
});
return {
displaced: emptyGroups,
displacedBy,
at: {
type: 'REORDER',
destination: {
droppableId: destination.descriptor.id,
index: newIndex,
},
},
};
}
export default function calculateReorderImpact({
draggable,
insideDestination,
destination,
viewport,
displacedBy,
last,
index,
forceShouldAnimate,
}: Args): DragImpact {
const inHomeList: boolean = isHomeOf(draggable, destination);
// Go into last spot of list
if (index == null) {
return goAtEnd({
insideDestination,
inHomeList,
displacedBy,
destination,
});
}
// this might be the dragging item
const match = insideDestination.find(
(item: DraggableDimension) => item.descriptor.index === index,
);
if (!match) {
return goAtEnd({
insideDestination,
inHomeList,
displacedBy,
destination,
});
}
const withoutDragging: DraggableDimension[] = removeDraggableFromList(
draggable,
insideDestination,
);
const sliceFrom: number = insideDestination.indexOf(match);
const impacted: DraggableDimension[] = withoutDragging.slice(sliceFrom);
const displaced: DisplacementGroups = getDisplacementGroups({
afterDragging: impacted,
destination,
displacedBy,
last,
viewport: viewport.frame,
forceShouldAnimate,
});
return {
displaced,
displacedBy,
at: {
type: 'REORDER',
destination: {
droppableId: destination.descriptor.id,
index,
},
},
};
}
+28
View File
@@ -0,0 +1,28 @@
import type { State, DraggableId } from '../types';
export default (state: State, id: DraggableId): boolean => {
// Ready to go!
if (state.phase === 'IDLE') {
return true;
}
// Can lift depending on the type of drop animation
if (state.phase !== 'DROP_ANIMATING') {
return false;
}
// - For a user drop we allow the user to drag other Draggables
// immediately as items are most likely already in their home
// - For a cancel items will be moving back to their original position
// as such it is a cleaner experience to block them from dragging until
// the drop animation is complete. Otherwise they will be grabbing
// items not in their original position which can lead to bad visuals
// Not allowing dragging of the dropping draggable
if (state.completed.result.draggableId === id) {
return false;
}
// if dropping - allow lifting
// if cancelling - disallow lifting
return state.completed.result.reason === 'DROP';
};
+110
View File
@@ -0,0 +1,110 @@
/* eslint-disable no-underscore-dangle */
import { applyMiddleware, createStore, compose, StoreEnhancer } from 'redux';
import reducer from './reducer';
import lift from './middleware/lift';
import style from './middleware/style';
import drop from './middleware/drop/drop-middleware';
import scrollListener from './middleware/scroll-listener';
import responders from './middleware/responders/responders-middleware';
import dropAnimationFinish from './middleware/drop/drop-animation-finish-middleware';
import dropAnimationFlushOnScroll from './middleware/drop/drop-animation-flush-on-scroll-middleware';
import dimensionMarshalStopper from './middleware/dimension-marshal-stopper';
import focus from './middleware/focus';
import autoScroll from './middleware/auto-scroll';
import pendingDrop from './middleware/pending-drop';
import type { DimensionMarshal } from './dimension-marshal/dimension-marshal-types';
import type { FocusMarshal } from '../view/use-focus-marshal/focus-marshal-types';
import type { StyleMarshal } from '../view/use-style-marshal/style-marshal-types';
import type { AutoScroller } from './auto-scroller/auto-scroller-types';
import type { Responders, Announce, State } from '../types';
import type { Store, Dispatch } from './store-types';
// For more config
// See: https://github.com/reduxjs/redux-devtools/blob/main/packages/redux-devtools-extension/src/index.ts#L3
interface Config {
name?: string;
}
export interface ReduxDevtoolsExtensionCompose {
(config: Config): (...funcs: StoreEnhancer[]) => StoreEnhancer;
(...funcs: StoreEnhancer[]): StoreEnhancer;
}
declare global {
interface Window {
__REDUX_DEVTOOLS_EXTENSION_COMPOSE__?: ReduxDevtoolsExtensionCompose;
}
}
// See: https://github.com/reduxjs/redux-devtools/blob/main/packages/redux-devtools-extension/src/index.ts#L219-L222
const composeEnhancers =
process.env.NODE_ENV !== 'production' &&
typeof window !== 'undefined' &&
window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({
name: '@hello-pangea/dnd',
})
: compose;
interface Args {
dimensionMarshal: DimensionMarshal;
focusMarshal: FocusMarshal;
styleMarshal: StyleMarshal;
getResponders: () => Responders;
announce: Announce;
autoScroller: AutoScroller;
}
export default ({
dimensionMarshal,
focusMarshal,
styleMarshal,
getResponders,
announce,
autoScroller,
}: Args): Store =>
createStore(
reducer,
composeEnhancers(
applyMiddleware<Dispatch, State>(
// ## Debug middleware
// > uncomment to use
// debugging logger
// require('../debug/middleware/log').default('light'),
// // user timing api
// require('../debug/middleware/user-timing').default,
// debugging timer
// require('../debug/middleware/action-timing').default,
// average action timer
// require('../debug/middleware/action-timing-average').default(200),
// ## Application middleware
// Style updates do not cause more actions. It is important to update styles
// before responders are called: specifically the onDragEnd responder. We need to clear
// the transition styles off the elements before a reorder to prevent strange
// post drag animations in firefox. Even though we clear the transition off
// a Draggable - if it is done after a reorder firefox will still apply the
// transition.
// Must be called before dimension marshal for lifting to apply collecting styles
style(styleMarshal),
// Stop the dimension marshal collecting anything
// when moving into a phase where collection is no longer needed.
// We need to stop the marshal before responders fire as responders can cause
// dimension registration changes in response to reordering
dimensionMarshalStopper(dimensionMarshal),
// Fire application responders in response to drag changes
lift(dimensionMarshal),
drop,
// When a drop animation finishes - fire a drop complete
dropAnimationFinish,
dropAnimationFlushOnScroll,
pendingDrop,
autoScroll(autoScroller),
scrollListener,
focus(focusMarshal),
// Fire responders for consumers (after update to store)
responders(getResponders, announce),
),
),
);
+8
View File
@@ -0,0 +1,8 @@
import type { DraggableId, LiftEffect } from '../types';
export default function didStartAfterCritical(
draggableId: DraggableId,
afterCritical: LiftEffect,
): boolean {
return Boolean(afterCritical.effected[draggableId]);
}
@@ -0,0 +1,44 @@
import type { Position } from 'css-box-model';
import type {
CollectionStartingActionCreator,
PublishWhileDraggingActionCreator,
UpdateDroppableScrollActionCreator,
UpdateDroppableIsEnabledActionCreator,
UpdateDroppableIsCombineEnabledActionCreator,
} from '../action-creators';
import type {
DroppableId,
Critical,
DimensionMap,
LiftRequest,
Viewport,
} from '../../types';
export interface StartPublishingResult {
critical: Critical;
dimensions: DimensionMap;
viewport: Viewport;
}
export interface DimensionMarshal {
// it is possible for a droppable to change whether it is enabled during a drag
updateDroppableIsEnabled: (id: DroppableId, isEnabled: boolean) => void;
// it is also possible to update whether combining is enabled
updateDroppableIsCombineEnabled: (
id: DroppableId,
isEnabled: boolean,
) => void;
updateDroppableScroll: (id: DroppableId, newScroll: Position) => void;
scrollDroppable: (id: DroppableId, change: Position) => void;
// Entry
startPublishing: (request: LiftRequest) => StartPublishingResult;
stopPublishing: () => void;
}
export interface Callbacks {
collectionStarting: CollectionStartingActionCreator;
publishWhileDragging: PublishWhileDraggingActionCreator;
updateDroppableScroll: UpdateDroppableScrollActionCreator;
updateDroppableIsEnabled: UpdateDroppableIsEnabledActionCreator;
updateDroppableIsCombineEnabled: UpdateDroppableIsCombineEnabledActionCreator;
}
@@ -0,0 +1,216 @@
import type { Position } from 'css-box-model';
import { invariant } from '../../invariant';
import type {
DimensionMarshal,
Callbacks,
StartPublishingResult,
} from './dimension-marshal-types';
import createPublisher from './while-dragging-publisher';
import type { WhileDraggingPublisher } from './while-dragging-publisher';
import getInitialPublish from './get-initial-publish';
import type {
Registry,
DroppableEntry,
DraggableEntry,
Subscriber,
Unsubscribe,
RegistryEvent,
} from '../registry/registry-types';
import type {
DroppableId,
DroppableDescriptor,
LiftRequest,
Critical,
DraggableDescriptor,
} from '../../types';
import { warning } from '../../dev-warning';
interface Collection {
critical: Critical;
unsubscribe: Unsubscribe;
}
function shouldPublishUpdate(
registry: Registry,
dragging: DraggableDescriptor,
entry: DraggableEntry,
): boolean {
// do not publish updates for the critical draggable
if (entry.descriptor.id === dragging.id) {
return false;
}
// do not publish updates for draggables that are not of a type that we care about
if (entry.descriptor.type !== dragging.type) {
return false;
}
const home: DroppableEntry = registry.droppable.getById(
entry.descriptor.droppableId,
);
if (home.descriptor.mode !== 'virtual') {
warning(`
You are attempting to add or remove a Draggable [id: ${entry.descriptor.id}]
while a drag is occurring. This is only supported for virtual lists.
See https://github.com/hello-pangea/dnd/blob/main/docs/patterns/virtual-lists.md
`);
return false;
}
return true;
}
export default (registry: Registry, callbacks: Callbacks) => {
let collection: Collection | null = null;
const publisher: WhileDraggingPublisher = createPublisher({
callbacks: {
publish: callbacks.publishWhileDragging,
collectionStarting: callbacks.collectionStarting,
},
registry,
});
const updateDroppableIsEnabled = (id: DroppableId, isEnabled: boolean) => {
invariant(
registry.droppable.exists(id),
`Cannot update is enabled flag of Droppable ${id} as it is not registered`,
);
// no need to update the application state if a collection is not occurring
if (!collection) {
return;
}
// At this point a non primary droppable dimension might not yet be published
// but may have its enabled state changed. For now we still publish this change
// and let the reducer exit early if it cannot find the dimension in the state.
callbacks.updateDroppableIsEnabled({ id, isEnabled });
};
const updateDroppableIsCombineEnabled = (
id: DroppableId,
isCombineEnabled: boolean,
) => {
// no need to update
if (!collection) {
return;
}
invariant(
registry.droppable.exists(id),
`Cannot update isCombineEnabled flag of Droppable ${id} as it is not registered`,
);
callbacks.updateDroppableIsCombineEnabled({ id, isCombineEnabled });
};
const updateDroppableScroll = (id: DroppableId, newScroll: Position) => {
// no need to update the application state if a collection is not occurring
if (!collection) {
return;
}
invariant(
registry.droppable.exists(id),
`Cannot update the scroll on Droppable ${id} as it is not registered`,
);
callbacks.updateDroppableScroll({ id, newScroll });
};
const scrollDroppable = (id: DroppableId, change: Position) => {
if (!collection) {
return;
}
registry.droppable.getById(id).callbacks.scroll(change);
};
const stopPublishing = () => {
// This function can be called defensively
if (!collection) {
return;
}
// Stop any pending dom collections or publish
publisher.stop();
// Tell all droppables to stop watching scroll
// all good if they where not already listening
const home: DroppableDescriptor = collection.critical.droppable;
registry.droppable
.getAllByType(home.type)
.forEach((entry: DroppableEntry) => entry.callbacks.dragStopped());
// Unsubscribe from registry updates
collection.unsubscribe();
// Finally - clear our collection
collection = null;
};
const subscriber: Subscriber = (event: RegistryEvent) => {
invariant(
collection,
'Should only be subscribed when a collection is occurring',
);
// The dragging item can be add and removed when using a clone
// We do not publish updates for the critical item
const dragging: DraggableDescriptor = collection.critical.draggable;
if (event.type === 'ADDITION') {
if (shouldPublishUpdate(registry, dragging, event.value)) {
publisher.add(event.value);
}
}
if (event.type === 'REMOVAL') {
if (shouldPublishUpdate(registry, dragging, event.value)) {
publisher.remove(event.value);
}
}
};
const startPublishing = (request: LiftRequest): StartPublishingResult => {
invariant(
!collection,
'Cannot start capturing critical dimensions as there is already a collection',
);
const entry: DraggableEntry = registry.draggable.getById(
request.draggableId,
);
const home: DroppableEntry = registry.droppable.getById(
entry.descriptor.droppableId,
);
const critical: Critical = {
draggable: entry.descriptor,
droppable: home.descriptor,
};
const unsubscribe = registry.subscribe(subscriber);
collection = {
critical,
unsubscribe,
};
return getInitialPublish({
critical,
registry,
scrollOptions: request.scrollOptions,
});
};
const marshal: DimensionMarshal = {
// Droppable changes
updateDroppableIsEnabled,
updateDroppableIsCombineEnabled,
scrollDroppable,
updateDroppableScroll,
// Entry
startPublishing,
stopPublishing,
};
return marshal;
};
@@ -0,0 +1,67 @@
import type { Position } from 'css-box-model';
import * as timings from '../../debug/timings';
import type { StartPublishingResult } from './dimension-marshal-types';
import type {
Registry,
DraggableEntry,
DroppableEntry,
} from '../registry/registry-types';
import { toDraggableMap, toDroppableMap } from '../dimension-structures';
import type {
DroppableDescriptor,
DroppableDimension,
DraggableDimension,
DimensionMap,
ScrollOptions,
Critical,
Viewport,
} from '../../types';
import getViewport from '../../view/window/get-viewport';
interface Args {
critical: Critical;
scrollOptions: ScrollOptions;
registry: Registry;
}
export default ({
critical,
scrollOptions,
registry,
}: Args): StartPublishingResult => {
const timingKey = 'Initial collection from DOM';
timings.start(timingKey);
const viewport: Viewport = getViewport();
const windowScroll: Position = viewport.scroll.current;
const home: DroppableDescriptor = critical.droppable;
const droppables: DroppableDimension[] = registry.droppable
.getAllByType(home.type)
.map(
(entry: DroppableEntry): DroppableDimension =>
entry.callbacks.getDimensionAndWatchScroll(windowScroll, scrollOptions),
);
const draggables: DraggableDimension[] = registry.draggable
.getAllByType(critical.draggable.type)
.map(
(entry: DraggableEntry): DraggableDimension =>
entry.getDimension(windowScroll),
);
const dimensions: DimensionMap = {
draggables: toDraggableMap(draggables),
droppables: toDroppableMap(droppables),
};
timings.finish(timingKey);
const result: StartPublishingResult = {
dimensions,
critical,
viewport,
};
return result;
};
@@ -0,0 +1,145 @@
import type { Position } from 'css-box-model';
import type {
DraggableId,
DroppableId,
DraggableDescriptor,
Published,
DraggableDimension,
DroppablePublish,
DroppableIdMap,
DraggableIdMap,
} from '../../types';
import type {
DroppableEntry,
Registry,
DraggableEntry,
DraggableEntryMap,
} from '../registry/registry-types';
import * as timings from '../../debug/timings';
import { origin } from '../position';
export interface WhileDraggingPublisher {
add: (entry: DraggableEntry) => void;
remove: (entry: DraggableEntry) => void;
stop: () => void;
}
interface Staging {
additions: DraggableEntryMap;
removals: DraggableIdMap;
modified: DroppableIdMap;
}
interface Callbacks {
publish: (args: Published) => unknown;
collectionStarting: () => unknown;
}
interface Args {
registry: Registry;
callbacks: Callbacks;
}
const clean = (): Staging => ({
additions: {},
removals: {},
modified: {},
});
const timingKey = 'Publish collection from DOM';
export default function createPublisher({
registry,
callbacks,
}: Args): WhileDraggingPublisher {
let staging: Staging = clean();
let frameId: AnimationFrameID | null = null;
const collect = () => {
if (frameId) {
return;
}
callbacks.collectionStarting();
frameId = requestAnimationFrame(() => {
frameId = null;
timings.start(timingKey);
const { additions, removals, modified } = staging;
const added: DraggableDimension[] = Object.keys(additions)
.map(
// Using the origin as the window scroll. This will be adjusted when processing the published values
(id: DraggableId): DraggableDimension =>
registry.draggable.getById(id).getDimension(origin),
)
// Dimensions are not guarenteed to be ordered in the same order as keys
// So we need to sort them so they are in the correct order
.sort(
(a: DraggableDimension, b: DraggableDimension): number =>
a.descriptor.index - b.descriptor.index,
);
const updated: DroppablePublish[] = Object.keys(modified).map(
(id: DroppableId) => {
const entry: DroppableEntry = registry.droppable.getById(id);
const scroll: Position = entry.callbacks.getScrollWhileDragging();
return {
droppableId: id,
scroll,
};
},
);
const result: Published = {
additions: added,
removals: Object.keys(removals),
modified: updated,
};
staging = clean();
timings.finish(timingKey);
callbacks.publish(result);
});
};
const add = (entry: DraggableEntry) => {
const id: DraggableId = entry.descriptor.id;
staging.additions[id] = entry;
staging.modified[entry.descriptor.droppableId] = true;
if (staging.removals[id]) {
delete staging.removals[id];
}
collect();
};
const remove = (entry: DraggableEntry) => {
const descriptor: DraggableDescriptor = entry.descriptor;
staging.removals[descriptor.id] = true;
staging.modified[descriptor.droppableId] = true;
if (staging.additions[descriptor.id]) {
delete staging.additions[descriptor.id];
}
collect();
};
const stop = () => {
if (!frameId) {
return;
}
cancelAnimationFrame(frameId);
frameId = null;
staging = clean();
};
return {
add,
remove,
stop,
};
}
+31
View File
@@ -0,0 +1,31 @@
import memoizeOne from 'memoize-one';
import type {
DroppableDimension,
DroppableDimensionMap,
DraggableDimension,
DraggableDimensionMap,
} from '../types';
export const toDroppableMap = memoizeOne((droppables: DroppableDimension[]) =>
droppables.reduce<DroppableDimensionMap>((previous, current) => {
previous[current.descriptor.id] = current;
return previous;
}, {}),
);
export const toDraggableMap = memoizeOne((draggables: DraggableDimension[]) =>
draggables.reduce<DraggableDimensionMap>((previous, current) => {
previous[current.descriptor.id] = current;
return previous;
}, {}),
);
export const toDroppableList = memoizeOne(
(droppables: DroppableDimensionMap): DroppableDimension[] =>
Object.values(droppables),
);
export const toDraggableList = memoizeOne(
(draggables: DraggableDimensionMap): DraggableDimension[] =>
Object.values(draggables),
);
+100
View File
@@ -0,0 +1,100 @@
import type { BoxModel, Position } from 'css-box-model';
import type {
Axis,
DroppableDimension,
DroppableDescriptor,
Scrollable,
DroppableSubject,
ScrollSize,
} from '../../types';
import { vertical, horizontal } from '../axis';
import { origin } from '../position';
import getMaxScroll from '../get-max-scroll';
import getSubject from './util/get-subject';
export interface Closest {
client: BoxModel;
page: BoxModel;
scroll: Position;
scrollSize: ScrollSize;
shouldClipSubject: boolean;
}
interface Args {
descriptor: DroppableDescriptor;
isEnabled: boolean;
isCombineEnabled: boolean;
isFixedOnPage: boolean;
direction: 'vertical' | 'horizontal';
client: BoxModel;
// is null when in a fixed container
page: BoxModel;
closest?: Closest | null;
}
export default ({
descriptor,
isEnabled,
isCombineEnabled,
isFixedOnPage,
direction,
client,
page,
closest,
}: Args): DroppableDimension => {
const frame: Scrollable | null = (() => {
if (!closest) {
return null;
}
const { scrollSize, client: frameClient } = closest;
// scrollHeight and scrollWidth are based on the padding box
// https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight
const maxScroll: Position = getMaxScroll({
scrollHeight: scrollSize.scrollHeight,
scrollWidth: scrollSize.scrollWidth,
height: frameClient.paddingBox.height,
width: frameClient.paddingBox.width,
});
return {
pageMarginBox: closest.page.marginBox,
frameClient,
scrollSize,
shouldClipSubject: closest.shouldClipSubject,
scroll: {
initial: closest.scroll,
current: closest.scroll,
max: maxScroll,
diff: {
value: origin,
displacement: origin,
},
},
};
})();
const axis: Axis = direction === 'vertical' ? vertical : horizontal;
const subject: DroppableSubject = getSubject({
page,
withPlaceholder: null,
axis,
frame,
});
const dimension: DroppableDimension = {
descriptor,
isCombineEnabled,
isFixedOnPage,
axis,
isEnabled,
client,
page,
frame,
subject,
};
return dimension;
};
+6
View File
@@ -0,0 +1,6 @@
import type { DraggableDimension, DroppableDimension } from '../../types';
export default (
draggable: DraggableDimension,
destination: DroppableDimension,
): boolean => draggable.descriptor.droppableId === destination.descriptor.id;
+52
View File
@@ -0,0 +1,52 @@
import type { Position } from 'css-box-model';
import { invariant } from '../../invariant';
import type {
DroppableDimension,
Scrollable,
DroppableSubject,
} from '../../types';
import { negate, subtract } from '../position';
import getSubject from './util/get-subject';
export default (
droppable: DroppableDimension,
newScroll: Position,
): DroppableDimension => {
invariant(droppable.frame);
const scrollable: Scrollable = droppable.frame;
const scrollDiff: Position = subtract(newScroll, scrollable.scroll.initial);
// a positive scroll difference leads to a negative displacement
// (scrolling down pulls an item upwards)
const scrollDisplacement: Position = negate(scrollDiff);
// Sometimes it is possible to scroll beyond the max point.
// This can occur when scrolling a foreign list that now has a placeholder.
const frame: Scrollable = {
...scrollable,
scroll: {
initial: scrollable.scroll.initial,
current: newScroll,
diff: {
value: scrollDiff,
displacement: scrollDisplacement,
},
// TODO: rename 'softMax?'
max: scrollable.scroll.max,
},
};
const subject: DroppableSubject = getSubject({
page: droppable.subject.page,
withPlaceholder: droppable.subject.withPlaceholder,
axis: droppable.axis,
frame,
});
const result: DroppableDimension = {
...droppable,
frame,
subject,
};
return result;
};
@@ -0,0 +1,6 @@
import type { DraggableDescriptor, DragImpact } from '../../types';
import whatIsDraggedOver from './what-is-dragged-over';
// use placeholder if dragged over
export default (descriptor: DraggableDescriptor, impact: DragImpact): boolean =>
whatIsDraggedOver(impact) === descriptor.droppableId;
+18
View File
@@ -0,0 +1,18 @@
import { getRect } from 'css-box-model';
import type { Rect, Spacing } from 'css-box-model';
export default (frame: Spacing, subject: Spacing): Rect | null => {
const result: Rect = getRect({
top: Math.max(subject.top, frame.top),
right: Math.min(subject.right, frame.right),
bottom: Math.min(subject.bottom, frame.bottom),
left: Math.max(subject.left, frame.left),
});
if (result.width <= 0 || result.height <= 0) {
return null;
}
return result;
};
+63
View File
@@ -0,0 +1,63 @@
import { getRect } from 'css-box-model';
import type { Rect, Spacing, BoxModel } from 'css-box-model';
import type {
Axis,
Scrollable,
DroppableSubject,
PlaceholderInSubject,
} from '../../../types';
import executeClip from './clip';
import { offsetByPosition } from '../../spacing';
const scroll = (target: Spacing, frame?: Scrollable | null): Spacing => {
if (!frame) {
return target;
}
return offsetByPosition(target, frame.scroll.diff.displacement);
};
const increase = (
target: Spacing,
axis: Axis,
withPlaceholder?: PlaceholderInSubject | null,
): Spacing => {
if (withPlaceholder && withPlaceholder.increasedBy) {
return {
...target,
[axis.end]: target[axis.end] + withPlaceholder.increasedBy[axis.line],
};
}
return target;
};
const clip = (target: Spacing, frame?: Scrollable | null): Rect | null => {
if (frame && frame.shouldClipSubject) {
return executeClip(frame.pageMarginBox, target);
}
return getRect(target);
};
interface Args {
page: BoxModel;
withPlaceholder: PlaceholderInSubject | null;
axis: Axis;
frame: Scrollable | null;
}
export default ({
page,
withPlaceholder,
axis,
frame,
}: Args): DroppableSubject => {
const scrolled: Spacing = scroll(page.marginBox, frame);
const increased: Spacing = increase(scrolled, axis, withPlaceholder);
const clipped: Rect | null = clip(increased, frame);
return {
page,
withPlaceholder,
active: clipped,
};
};
@@ -0,0 +1,15 @@
import type { DroppableId, DropResult } from '../../types';
export default (result: DropResult): DroppableId | null => {
const { combine, destination } = result;
if (destination) {
return destination.droppableId;
}
if (combine) {
return combine.droppableId;
}
return null;
};
@@ -0,0 +1,15 @@
import type { ImpactLocation, DroppableId, DragImpact } from '../../types';
export default (impact: DragImpact): DroppableId | null => {
const at: ImpactLocation | null = impact.at;
if (!at) {
return null;
}
if (at.type === 'REORDER') {
return at.destination.droppableId;
}
return at.combine.droppableId;
};
+174
View File
@@ -0,0 +1,174 @@
import type { Position } from 'css-box-model';
import { invariant } from '../../invariant';
import type {
Axis,
DroppableDimension,
DraggableDimension,
DraggableDimensionMap,
Scrollable,
DroppableSubject,
PlaceholderInSubject,
} from '../../types';
import getDraggablesInsideDroppable from '../get-draggables-inside-droppable';
import { add, patch } from '../position';
import getSubject from './util/get-subject';
import isHomeOf from './is-home-of';
import getDisplacedBy from '../get-displaced-by';
const getRequiredGrowthForPlaceholder = (
droppable: DroppableDimension,
placeholderSize: Position,
draggables: DraggableDimensionMap,
): Position | null => {
const axis: Axis = droppable.axis;
// A virtual list will most likely not contain all of the Draggables
// so counting them does not help.
if (droppable.descriptor.mode === 'virtual') {
return patch(axis.line, placeholderSize[axis.line]);
}
// TODO: consider margin collapsing?
// Using contentBox as that is where the Draggables will sit
const availableSpace: number = droppable.subject.page.contentBox[axis.size];
const insideDroppable: DraggableDimension[] = getDraggablesInsideDroppable(
droppable.descriptor.id,
draggables,
);
const spaceUsed: number = insideDroppable.reduce(
(sum: number, dimension: DraggableDimension): number =>
sum + dimension.client.marginBox[axis.size],
0,
);
const requiredSpace: number = spaceUsed + placeholderSize[axis.line];
const needsToGrowBy: number = requiredSpace - availableSpace;
// nothing to do here
if (needsToGrowBy <= 0) {
return null;
}
return patch(axis.line, needsToGrowBy);
};
const withMaxScroll = (frame: Scrollable, max: Position): Scrollable => ({
...frame,
scroll: {
...frame.scroll,
max,
},
});
export const addPlaceholder = (
droppable: DroppableDimension,
draggable: DraggableDimension,
draggables: DraggableDimensionMap,
): DroppableDimension => {
const frame: Scrollable | null = droppable.frame;
invariant(
!isHomeOf(draggable, droppable),
'Should not add placeholder space to home list',
);
invariant(
!droppable.subject.withPlaceholder,
'Cannot add placeholder size to a subject when it already has one',
);
const placeholderSize: Position = getDisplacedBy(
droppable.axis,
draggable.displaceBy,
).point;
const requiredGrowth: Position | null = getRequiredGrowthForPlaceholder(
droppable,
placeholderSize,
draggables,
);
const added: PlaceholderInSubject = {
placeholderSize,
increasedBy: requiredGrowth,
oldFrameMaxScroll: droppable.frame ? droppable.frame.scroll.max : null,
};
if (!frame) {
const subject: DroppableSubject = getSubject({
page: droppable.subject.page,
withPlaceholder: added,
axis: droppable.axis,
frame: droppable.frame,
});
return {
...droppable,
subject,
};
}
const maxScroll: Position = requiredGrowth
? add(frame.scroll.max, requiredGrowth)
: frame.scroll.max;
const newFrame: Scrollable = withMaxScroll(frame, maxScroll);
const subject: DroppableSubject = getSubject({
page: droppable.subject.page,
withPlaceholder: added,
axis: droppable.axis,
frame: newFrame,
});
return {
...droppable,
subject,
frame: newFrame,
};
};
export const removePlaceholder = (
droppable: DroppableDimension,
): DroppableDimension => {
const added: PlaceholderInSubject | null = droppable.subject.withPlaceholder;
invariant(
added,
'Cannot remove placeholder form subject when there was none',
);
const frame: Scrollable | null = droppable.frame;
if (!frame) {
const subject: DroppableSubject = getSubject({
page: droppable.subject.page,
axis: droppable.axis,
frame: null,
// cleared
withPlaceholder: null,
});
return {
...droppable,
subject,
};
}
const oldMaxScroll: Position | null = added.oldFrameMaxScroll;
invariant(
oldMaxScroll,
'Expected droppable with frame to have old max frame scroll when removing placeholder',
);
const newFrame: Scrollable = withMaxScroll(frame, oldMaxScroll);
const subject: DroppableSubject = getSubject({
page: droppable.subject.page,
axis: droppable.axis,
frame: newFrame,
// cleared
withPlaceholder: null,
});
return {
...droppable,
subject,
frame: newFrame,
};
};
@@ -0,0 +1,28 @@
import type { Position } from 'css-box-model';
import type { Viewport, DraggableDimension } from '../../../types';
import { add, subtract } from '../../position';
import withViewportDisplacement from '../../with-scroll-change/with-viewport-displacement';
interface Args {
pageBorderBoxCenter: Position;
draggable: DraggableDimension;
viewport: Viewport;
}
export default ({
pageBorderBoxCenter,
draggable,
viewport,
}: Args): Position => {
const withoutPageScrollChange: Position = withViewportDisplacement(
viewport,
pageBorderBoxCenter,
);
const offset: Position = subtract(
withoutPageScrollChange,
draggable.page.borderBox.center,
);
return add(draggable.client.borderBox.center, offset);
};
@@ -0,0 +1,43 @@
import type { Position } from 'css-box-model';
import type {
DroppableDimension,
Viewport,
DragImpact,
DraggableDimension,
DraggableDimensionMap,
LiftEffect,
} from '../../../types';
import getPageBorderBoxCenterFromImpact from '../get-page-border-box-center';
import getClientFromPageBorderBoxCenter from './get-client-from-page-border-box-center';
interface Args {
impact: DragImpact;
draggable: DraggableDimension;
droppable: DroppableDimension;
draggables: DraggableDimensionMap;
viewport: Viewport;
afterCritical: LiftEffect;
}
export default ({
impact,
draggable,
droppable,
draggables,
viewport,
afterCritical,
}: Args): Position => {
const pageBorderBoxCenter: Position = getPageBorderBoxCenterFromImpact({
impact,
draggable,
draggables,
droppable,
afterCritical,
});
return getClientFromPageBorderBoxCenter({
pageBorderBoxCenter,
draggable,
viewport,
});
};
@@ -0,0 +1,68 @@
import type { Position } from 'css-box-model';
import type {
DragImpact,
DraggableDimension,
DroppableDimension,
DraggableDimensionMap,
ImpactLocation,
LiftEffect,
} from '../../../types';
import whenCombining from './when-combining';
import whenReordering from './when-reordering';
import withDroppableDisplacement from '../../with-scroll-change/with-droppable-displacement';
interface Args {
impact: DragImpact;
afterCritical: LiftEffect;
draggable: DraggableDimension;
droppable: DroppableDimension | null;
draggables: DraggableDimensionMap;
}
const getResultWithoutDroppableDisplacement = ({
impact,
draggable,
droppable,
draggables,
afterCritical,
}: Args): Position => {
const original: Position = draggable.page.borderBox.center;
const at: ImpactLocation | null = impact.at;
if (!droppable) {
return original;
}
if (!at) {
return original;
}
if (at.type === 'REORDER') {
return whenReordering({
impact,
draggable,
draggables,
droppable,
afterCritical,
});
}
return whenCombining({
impact,
draggables,
afterCritical,
});
};
export default (args: Args): Position => {
const withoutDisplacement: Position =
getResultWithoutDroppableDisplacement(args);
const droppable: DroppableDimension | null = args.droppable;
const withDisplacement: Position = droppable
? withDroppableDisplacement(droppable, withoutDisplacement)
: withoutDisplacement;
return withDisplacement;
};
@@ -0,0 +1,38 @@
import type { Position } from 'css-box-model';
import { invariant } from '../../../invariant';
import type {
DraggableDimensionMap,
DraggableId,
Combine,
LiftEffect,
DragImpact,
} from '../../../types';
import { add } from '../../position';
import getCombinedItemDisplacement from '../../get-combined-item-displacement';
import { tryGetCombine } from '../../get-impact-location';
interface Args {
impact: DragImpact;
// all draggables in the system
draggables: DraggableDimensionMap;
afterCritical: LiftEffect;
}
// Returns the client offset required to move an item from its
// original client position to its final resting position
export default ({ afterCritical, impact, draggables }: Args): Position => {
const combine: Combine | null = tryGetCombine(impact);
invariant(combine);
const combineWith: DraggableId = combine.draggableId;
const center: Position = draggables[combineWith].page.borderBox.center;
const displaceBy: Position = getCombinedItemDisplacement({
displaced: impact.displaced,
afterCritical,
combineWith,
displacedBy: impact.displacedBy,
});
return add(center, displaceBy);
};
@@ -0,0 +1,112 @@
import { offset } from 'css-box-model';
import type { Position, BoxModel } from 'css-box-model';
import type {
Axis,
DragImpact,
DraggableId,
DraggableDimension,
DraggableDimensionMap,
DroppableDimension,
LiftEffect,
} from '../../../types';
import { goBefore, goAfter, goIntoStart } from '../move-relative-to';
import getDraggablesInsideDroppable from '../../get-draggables-inside-droppable';
import { negate } from '../../position';
import didStartAfterCritical from '../../did-start-after-critical';
interface NewHomeArgs {
impact: DragImpact;
draggable: DraggableDimension;
draggables: DraggableDimensionMap;
droppable: DroppableDimension;
afterCritical: LiftEffect;
}
// Returns the client offset required to move an item from its
// original client position to its final resting position
export default ({
impact,
draggable,
draggables,
droppable,
afterCritical,
}: NewHomeArgs): Position => {
const insideDestination: DraggableDimension[] = getDraggablesInsideDroppable(
droppable.descriptor.id,
draggables,
);
const draggablePage: BoxModel = draggable.page;
const axis: Axis = droppable.axis;
// this will only happen in a foreign list
if (!insideDestination.length) {
return goIntoStart({
axis,
moveInto: droppable.page,
isMoving: draggablePage,
});
}
const { displaced, displacedBy } = impact;
const closestAfter: DraggableId | null = displaced.all[0];
// go before the first displaced item
// items can only be displaced forwards
if (closestAfter) {
const closest: DraggableDimension = draggables[closestAfter];
// want to go before where it would be with the displacement
// target is displaced and is already in it's starting position
if (didStartAfterCritical(closestAfter, afterCritical)) {
return goBefore({
axis,
moveRelativeTo: closest.page,
isMoving: draggablePage,
});
}
// target has been displaced during the drag and it is not in its starting position
// we need to account for the displacement
const withDisplacement: BoxModel = offset(closest.page, displacedBy.point);
return goBefore({
axis,
moveRelativeTo: withDisplacement,
isMoving: draggablePage,
});
}
// Nothing in list is displaced, we should go after the last item
const last: DraggableDimension =
insideDestination[insideDestination.length - 1];
// we can just go into our original position if the last item
// is the dragging item
if (last.descriptor.id === draggable.descriptor.id) {
return draggablePage.borderBox.center;
}
if (didStartAfterCritical(last.descriptor.id, afterCritical)) {
// if the item started displaced and it is no longer displaced then
// we need to go after it it's non-displaced position
const page: BoxModel = offset(
last.page,
negate(afterCritical.displacedBy.point),
);
return goAfter({
axis,
moveRelativeTo: page,
isMoving: draggablePage,
});
}
// item is in its resting spot. we can go straight after it
return goAfter({
axis,
moveRelativeTo: last.page,
isMoving: draggablePage,
});
};
@@ -0,0 +1,66 @@
import type { Position, BoxModel, Rect } from 'css-box-model';
import { patch } from '../position';
import type { Axis } from '../../types';
interface Args {
axis: Axis;
moveRelativeTo: BoxModel;
isMoving: BoxModel;
}
const distanceFromStartToBorderBoxCenter = (
axis: Axis,
box: BoxModel,
): number => box.margin[axis.start] + box.borderBox[axis.size] / 2;
const distanceFromEndToBorderBoxCenter = (axis: Axis, box: BoxModel): number =>
box.margin[axis.end] + box.borderBox[axis.size] / 2;
// We align the moving item against the cross axis start of the target
// We used to align the moving item cross axis center with the cross axis center of the target.
// However, this leads to a bad experience when reordering columns
const getCrossAxisBorderBoxCenter = (
axis: Axis,
target: Rect,
isMoving: BoxModel,
): number =>
target[axis.crossAxisStart] +
isMoving.margin[axis.crossAxisStart] +
isMoving.borderBox[axis.crossAxisSize] / 2;
export const goAfter = ({ axis, moveRelativeTo, isMoving }: Args): Position =>
patch(
axis.line,
// start measuring from the end of the target
moveRelativeTo.marginBox[axis.end] +
distanceFromStartToBorderBoxCenter(axis, isMoving),
getCrossAxisBorderBoxCenter(axis, moveRelativeTo.marginBox, isMoving),
);
export const goBefore = ({ axis, moveRelativeTo, isMoving }: Args): Position =>
patch(
axis.line,
// start measuring from the start of the target
moveRelativeTo.marginBox[axis.start] -
distanceFromEndToBorderBoxCenter(axis, isMoving),
getCrossAxisBorderBoxCenter(axis, moveRelativeTo.marginBox, isMoving),
);
interface GoIntoArgs {
axis: Axis;
moveInto: BoxModel;
isMoving: BoxModel;
}
// moves into the content box
export const goIntoStart = ({
axis,
moveInto,
isMoving,
}: GoIntoArgs): Position =>
patch(
axis.line,
moveInto.contentBox[axis.start] +
distanceFromStartToBorderBoxCenter(axis, isMoving),
getCrossAxisBorderBoxCenter(axis, moveInto.contentBox, isMoving),
);
@@ -0,0 +1,33 @@
import type { Position } from 'css-box-model';
import type {
DisplacementGroups,
LiftEffect,
DraggableId,
DisplacedBy,
} from '../types';
import { origin, negate } from './position';
import didStartAfterCritical from './did-start-after-critical';
interface Args {
displaced: DisplacementGroups;
afterCritical: LiftEffect;
combineWith: DraggableId;
displacedBy: DisplacedBy;
}
export default ({
displaced,
afterCritical,
combineWith,
displacedBy,
}: Args): Position => {
const isDisplaced = Boolean(
displaced.visible[combineWith] || displaced.invisible[combineWith],
);
if (didStartAfterCritical(combineWith, afterCritical)) {
return isDisplaced ? origin : negate(displacedBy.point);
}
return isDisplaced ? displacedBy.point : origin;
};
+16
View File
@@ -0,0 +1,16 @@
import memoizeOne from 'memoize-one';
import type { Position } from 'css-box-model';
import type { Axis, DisplacedBy } from '../types';
import { patch } from './position';
// TODO: memoization needed?
export default memoizeOne(function getDisplacedBy(
axis: Axis,
displaceBy: Position,
): DisplacedBy {
const displacement: number = displaceBy[axis.line];
return {
value: displacement,
point: patch(axis.line, displacement),
};
});
+130
View File
@@ -0,0 +1,130 @@
import { expand, getRect } from 'css-box-model';
import type { Rect, Spacing } from 'css-box-model';
import type {
DraggableId,
Displacement,
DraggableDimension,
DroppableDimension,
DisplacementGroups,
DisplacedBy,
} from '../types';
import { isPartiallyVisible } from './visibility/is-visible';
interface Args {
afterDragging: DraggableDimension[];
destination: DroppableDimension;
displacedBy: DisplacedBy;
last: DisplacementGroups | null;
viewport: Rect;
forceShouldAnimate?: boolean;
}
const getShouldAnimate = (
id: DraggableId,
last?: DisplacementGroups | null,
forceShouldAnimate?: boolean | null,
) => {
// Use a forced value if provided
if (typeof forceShouldAnimate === 'boolean') {
return forceShouldAnimate;
}
// nothing to gauge animation from
if (!last) {
return true;
}
const { invisible, visible } = last;
// it was previously invisible - no animation
if (invisible[id]) {
return false;
}
const previous: Displacement | null = visible[id];
return previous ? previous.shouldAnimate : true;
};
// Note: it is also an optimisation to not render the displacement on
// items when they are not longer visible.
// This prevents a lot of .render() calls when leaving / entering a list
function getTarget(
draggable: DraggableDimension,
displacedBy: DisplacedBy,
): Rect {
const marginBox: Rect = draggable.page.marginBox;
// ## Visibility overscanning
// We are expanding rather than offsetting the marginBox.
// In some cases we want
// - the target based on the starting position (such as when dropping outside of any list)
// - the target based on the items position without starting displacement (such as when moving inside a list)
// To keep things simple we just expand the whole area for this check
// The worst case is some minor redundant offscreen movements
const expandBy: Spacing = {
// pull backwards into viewport
top: displacedBy.point.y,
right: 0,
bottom: 0,
// pull backwards into viewport
left: displacedBy.point.x,
};
return getRect(expand(marginBox, expandBy));
}
export default function getDisplacementGroups({
afterDragging,
destination,
displacedBy,
viewport,
forceShouldAnimate,
last,
}: Args): DisplacementGroups {
return afterDragging.reduce(
function process(
groups: DisplacementGroups,
draggable: DraggableDimension,
): DisplacementGroups {
const target: Rect = getTarget(draggable, displacedBy);
const id: DraggableId = draggable.descriptor.id;
groups.all.push(id);
const isVisible: boolean = isPartiallyVisible({
target,
destination,
viewport,
withDroppableDisplacement: true,
});
if (!isVisible) {
groups.invisible[draggable.descriptor.id] = true;
return groups;
}
// item is visible
const shouldAnimate: boolean = getShouldAnimate(
id,
last,
forceShouldAnimate,
);
const displacement: Displacement = {
draggableId: id,
shouldAnimate,
};
groups.visible[id] = displacement;
return groups;
},
{
all: [],
visible: {},
invisible: {},
},
);
}
@@ -0,0 +1,125 @@
import type { Rect } from 'css-box-model';
import type {
DraggableId,
Axis,
DraggableDimension,
DroppableDimension,
DragImpact,
LiftEffect,
DisplacedBy,
} from '../../types';
import getDidStartAfterCritical from '../did-start-after-critical';
import getDisplacedBy from '../get-displaced-by';
import getIsDisplaced from '../get-is-displaced';
import removeDraggableFromList from '../remove-draggable-from-list';
interface Args {
draggable: DraggableDimension;
pageBorderBoxWithDroppableScroll: Rect;
previousImpact: DragImpact;
destination: DroppableDimension;
insideDestination: DraggableDimension[];
afterCritical: LiftEffect;
}
// exported for testing
export const combineThresholdDivisor = 4;
export default ({
draggable,
pageBorderBoxWithDroppableScroll: targetRect,
previousImpact,
destination,
insideDestination,
afterCritical,
}: Args): DragImpact | null => {
if (!destination.isCombineEnabled) {
return null;
}
const axis: Axis = destination.axis;
const displacedBy: DisplacedBy = getDisplacedBy(
destination.axis,
draggable.displaceBy,
);
const displacement: number = displacedBy.value;
const targetStart: number = targetRect[axis.start];
const targetEnd: number = targetRect[axis.end];
const withoutDragging: DraggableDimension[] = removeDraggableFromList(
draggable,
insideDestination,
);
const combineWith = withoutDragging.find((child): boolean => {
const id: DraggableId = child.descriptor.id;
const childRect: Rect = child.page.borderBox;
const childSize: number = childRect[axis.size];
const threshold: number = childSize / combineThresholdDivisor;
const didStartAfterCritical: boolean = getDidStartAfterCritical(
id,
afterCritical,
);
const isDisplaced: boolean = getIsDisplaced({
displaced: previousImpact.displaced,
id,
});
/*
Only combining when in the combine region
As soon as a boundary is hit then no longer combining
*/
if (didStartAfterCritical) {
// In original position
// Will combine with item when inside a band
if (isDisplaced) {
return (
targetEnd > childRect[axis.start] + threshold &&
targetEnd < childRect[axis.end] - threshold
);
}
// child is now 'displaced' backwards from where it started
// want to combine when we move backwards onto it
return (
targetStart > childRect[axis.start] - displacement + threshold &&
targetStart < childRect[axis.end] - displacement - threshold
);
}
// item has moved forwards
if (isDisplaced) {
return (
targetEnd > childRect[axis.start] + displacement + threshold &&
targetEnd < childRect[axis.end] + displacement - threshold
);
}
// is in resting position - being moved backwards on to
return (
targetStart > childRect[axis.start] + threshold &&
targetStart < childRect[axis.end] - threshold
);
});
if (!combineWith) {
return null;
}
const impact: DragImpact = {
// no change to displacement when combining
displacedBy,
displaced: previousImpact.displaced,
at: {
type: 'COMBINE',
combine: {
draggableId: combineWith.descriptor.id,
droppableId: destination.descriptor.id,
},
},
};
return impact;
};
@@ -0,0 +1,143 @@
import type { Rect } from 'css-box-model';
import type {
DraggableId,
DraggableDimension,
DroppableDimension,
DragImpact,
Axis,
DisplacementGroups,
Viewport,
DisplacedBy,
LiftEffect,
} from '../../types';
import getDisplacedBy from '../get-displaced-by';
import removeDraggableFromList from '../remove-draggable-from-list';
import isHomeOf from '../droppable/is-home-of';
import getDidStartAfterCritical from '../did-start-after-critical';
import calculateReorderImpact from '../calculate-drag-impact/calculate-reorder-impact';
import getIsDisplaced from '../get-is-displaced';
interface Args {
pageBorderBoxWithDroppableScroll: Rect;
draggable: DraggableDimension;
destination: DroppableDimension;
insideDestination: DraggableDimension[];
last: DisplacementGroups;
viewport: Viewport;
afterCritical: LiftEffect;
}
interface AtIndexArgs {
draggable: DraggableDimension;
closest: DraggableDimension | null;
inHomeList: boolean;
}
function atIndex({
draggable,
closest,
inHomeList,
}: AtIndexArgs): number | null {
if (!closest) {
return null;
}
if (!inHomeList) {
return closest.descriptor.index;
}
if (closest.descriptor.index > draggable.descriptor.index) {
return closest.descriptor.index - 1;
}
return closest.descriptor.index;
}
export default ({
pageBorderBoxWithDroppableScroll: targetRect,
draggable,
destination,
insideDestination,
last,
viewport,
afterCritical,
}: Args): DragImpact => {
const axis: Axis = destination.axis;
const displacedBy: DisplacedBy = getDisplacedBy(
destination.axis,
draggable.displaceBy,
);
const displacement: number = displacedBy.value;
const targetStart: number = targetRect[axis.start];
const targetEnd: number = targetRect[axis.end];
const withoutDragging: DraggableDimension[] = removeDraggableFromList(
draggable,
insideDestination,
);
const closest =
withoutDragging.find((child): boolean => {
const id: DraggableId = child.descriptor.id;
const childCenter: number = child.page.borderBox.center[axis.line];
const didStartAfterCritical: boolean = getDidStartAfterCritical(
id,
afterCritical,
);
const isDisplaced: boolean = getIsDisplaced({ displaced: last, id });
/*
Note: we change things when moving *past* the child center - not when it hits the center
If we make it when we *hit* the child center then there can be
a hit on the next update causing a flicker.
- Update 1: targetBottom hits center => displace backwards
- Update 2: targetStart is now hitting the displaced center => displace forwards
- Update 3: goto 1 (boom)
*/
if (didStartAfterCritical) {
// Continue to displace while targetEnd before the childCenter
// Move once we *move forward past* the childCenter
if (isDisplaced) {
return targetEnd <= childCenter;
}
// Has been moved backwards from where it started
// Displace forwards when targetStart *moves backwards past* the displaced childCenter
return targetStart < childCenter - displacement;
}
// Item has been shifted forward.
// Remove displacement when targetEnd moves forward past the displaced center
if (isDisplaced) {
return targetEnd <= childCenter + displacement;
}
// Item is behind the dragging item
// We want to displace it if the targetStart goes *backwards past* the childCenter
return targetStart < childCenter;
}) || null;
const newIndex: number | null = atIndex({
draggable,
closest,
inHomeList: isHomeOf(draggable, destination),
});
// TODO: index cannot be null?
// otherwise return null from there and return empty impact
// that was calculate reorder impact does not need to account for a null index
return calculateReorderImpact({
draggable,
insideDestination,
destination,
viewport,
last,
displacedBy,
index: newIndex,
});
};
+92
View File
@@ -0,0 +1,92 @@
import type { Position, Rect } from 'css-box-model';
import type {
DroppableId,
DraggableDimension,
DroppableDimension,
DraggableDimensionMap,
DroppableDimensionMap,
DragImpact,
Viewport,
LiftEffect,
} from '../../types';
import getDroppableOver from '../get-droppable-over';
import getDraggablesInsideDroppable from '../get-draggables-inside-droppable';
import withDroppableScroll from '../with-scroll-change/with-droppable-scroll';
import getReorderImpact from './get-reorder-impact';
import getCombineImpact from './get-combine-impact';
import noImpact from '../no-impact';
import { offsetRectByPosition } from '../rect';
interface Args {
pageOffset: Position;
draggable: DraggableDimension;
// all dimensions in system
draggables: DraggableDimensionMap;
droppables: DroppableDimensionMap;
previousImpact: DragImpact;
viewport: Viewport;
afterCritical: LiftEffect;
}
export default ({
pageOffset,
draggable,
draggables,
droppables,
previousImpact,
viewport,
afterCritical,
}: Args): DragImpact => {
const pageBorderBox: Rect = offsetRectByPosition(
draggable.page.borderBox,
pageOffset,
);
const destinationId: DroppableId | null = getDroppableOver({
pageBorderBox,
draggable,
droppables,
});
// not dragging over anything
if (!destinationId) {
// A big design decision was made here to collapse the home list
// when not over any list. This yielded the most consistently beautiful experience.
return noImpact;
}
const destination: DroppableDimension = droppables[destinationId];
const insideDestination: DraggableDimension[] = getDraggablesInsideDroppable(
destination.descriptor.id,
draggables,
);
// Where the element actually is now.
// Need to take into account the change of scroll in the droppable
const pageBorderBoxWithDroppableScroll: Rect = withDroppableScroll(
destination,
pageBorderBox,
);
// checking combine first so we combine before any reordering
return (
getCombineImpact({
pageBorderBoxWithDroppableScroll,
draggable,
previousImpact,
destination,
insideDestination,
afterCritical,
}) ||
getReorderImpact({
pageBorderBoxWithDroppableScroll,
draggable,
destination,
insideDestination,
last: previousImpact.displaced,
viewport,
afterCritical,
})
);
};
@@ -0,0 +1,30 @@
import memoizeOne from 'memoize-one';
import { toDraggableList } from './dimension-structures';
import type {
DraggableDimension,
DroppableId,
DraggableDimensionMap,
} from '../types';
export default memoizeOne(
(
// using droppableId to avoid cache busted when we
// update the droppable (such as when it scrolls)
droppableId: DroppableId,
draggables: DraggableDimensionMap,
): DraggableDimension[] => {
const result = toDraggableList(draggables)
.filter(
(draggable: DraggableDimension): boolean =>
droppableId === draggable.descriptor.droppableId,
)
// Dimensions are not guarenteed to be ordered in the same order as keys
// So we need to sort them so they are in the correct order
.sort(
(a: DraggableDimension, b: DraggableDimension): number =>
a.descriptor.index - b.descriptor.index,
);
return result;
},
);
+157
View File
@@ -0,0 +1,157 @@
import type { Position, Rect } from 'css-box-model';
import type {
DroppableDimension,
DroppableDimensionMap,
DroppableId,
DraggableDimension,
Axis,
} from '../types';
import { toDroppableList } from './dimension-structures';
import isPositionInFrame from './visibility/is-position-in-frame';
import { distance, patch } from './position';
import isWithin from './is-within';
// https://stackoverflow.com/questions/306316/determine-if-two-rectangles-overlap-each-other
// https://silentmatt.com/rectangle-intersection/
function getHasOverlap(first: Rect, second: Rect): boolean {
return (
first.left < second.right &&
first.right > second.left &&
first.top < second.bottom &&
first.bottom > second.top
);
}
interface Args {
pageBorderBox: Rect;
draggable: DraggableDimension;
droppables: DroppableDimensionMap;
}
interface WithDistance {
distance: number;
id: DroppableId;
}
interface GetFurthestArgs {
pageBorderBox: Rect;
draggable: DraggableDimension;
candidates: DroppableDimension[];
}
function getFurthestAway({
pageBorderBox,
draggable,
candidates,
}: GetFurthestArgs): DroppableId | null {
// We are not comparing the center of the home list with the target list as it would
// give preference to giant lists
// We are measuring the distance from where the draggable started
// to where it is *hitting* the candidate
// Note: The hit point might technically not be in the bounds of the candidate
const startCenter: Position = draggable.page.borderBox.center;
const sorted: WithDistance[] = candidates
.map((candidate: DroppableDimension): WithDistance => {
const axis: Axis = candidate.axis;
const target: Position = patch(
candidate.axis.line,
// use the current center of the dragging item on the main axis
pageBorderBox.center[axis.line],
// use the center of the list on the cross axis
candidate.page.borderBox.center[axis.crossAxisLine],
);
return {
id: candidate.descriptor.id,
distance: distance(startCenter, target),
};
})
// largest value will be first
.sort((a: WithDistance, b: WithDistance) => b.distance - a.distance);
// just being safe
return sorted[0] ? sorted[0].id : null;
}
export default function getDroppableOver({
pageBorderBox,
draggable,
droppables,
}: Args): DroppableId | null {
// We know at this point that some overlap has to exist
const candidates: DroppableDimension[] = toDroppableList(droppables).filter(
(item: DroppableDimension): boolean => {
// Cannot be a candidate when disabled
if (!item.isEnabled) {
return false;
}
// Cannot be a candidate when there is no visible area
const active: Rect | null = item.subject.active;
if (!active) {
return false;
}
// Cannot be a candidate when dragging item is not over the droppable at all
if (!getHasOverlap(pageBorderBox, active)) {
return false;
}
// 1. Candidate if the center position is over a droppable
if (isPositionInFrame(active)(pageBorderBox.center)) {
return true;
}
// 2. Candidate if an edge is over the cross axis half way point
// 3. Candidate if dragging item is totally over droppable on cross axis
const axis: Axis = item.axis;
const childCenter: number = active.center[axis.crossAxisLine];
const crossAxisStart: number = pageBorderBox[axis.crossAxisStart];
const crossAxisEnd: number = pageBorderBox[axis.crossAxisEnd];
const isContained = isWithin(
active[axis.crossAxisStart],
active[axis.crossAxisEnd],
);
const isStartContained: boolean = isContained(crossAxisStart);
const isEndContained: boolean = isContained(crossAxisEnd);
// Dragging item is totally covering the active area
if (!isStartContained && !isEndContained) {
return true;
}
/**
* edges must go beyond the center line in order to avoid
* cases were both conditions are satisfied.
*/
if (isStartContained) {
return crossAxisStart < childCenter;
}
return crossAxisEnd > childCenter;
},
);
if (!candidates.length) {
return null;
}
// Only one candidate - use that!
if (candidates.length === 1) {
return candidates[0].descriptor.id;
}
// Multiple options returned
// Should only occur with really large items
// Going to use fallback: distance from home
return getFurthestAway({
pageBorderBox,
draggable,
candidates,
});
}
+9
View File
@@ -0,0 +1,9 @@
import { invariant } from '../invariant';
import type { DroppableDimension, Scrollable } from '../types';
export default (droppable: DroppableDimension): Scrollable => {
const frame: Scrollable | null = droppable.frame;
invariant(frame, 'Expected Droppable to have a frame');
return frame;
};
+6
View File
@@ -0,0 +1,6 @@
import type { DraggableDescriptor, DraggableLocation } from '../types';
export default (descriptor: DraggableDescriptor): DraggableLocation => ({
index: descriptor.index,
droppableId: descriptor.droppableId,
});
+17
View File
@@ -0,0 +1,17 @@
import type { DragImpact, DraggableLocation, Combine } from '../types';
export function tryGetDestination(
impact: DragImpact,
): DraggableLocation | null {
if (impact.at && impact.at.type === 'REORDER') {
return impact.at.destination;
}
return null;
}
export function tryGetCombine(impact: DragImpact): Combine | null {
if (impact.at && impact.at.type === 'COMBINE') {
return impact.at.combine;
}
return null;
}
+10
View File
@@ -0,0 +1,10 @@
import type { DisplacementGroups, DraggableId } from '../types';
interface Args {
displaced: DisplacementGroups;
id: DraggableId;
}
export default function getIsDisplaced({ displaced, id }: Args): boolean {
return Boolean(displaced.visible[id] || displaced.invisible[id]);
}
+81
View File
@@ -0,0 +1,81 @@
import { invariant } from '../invariant';
import getHomeLocation from './get-home-location';
import type {
DraggableDimension,
DroppableDimension,
DraggableDimensionMap,
DragImpact,
DisplacedBy,
Viewport,
DraggableIdMap,
DisplacementGroups,
LiftEffect,
} from '../types';
import getDraggablesInsideDroppable from './get-draggables-inside-droppable';
import getDisplacedBy from './get-displaced-by';
import getDisplacementGroups from './get-displacement-groups';
interface Args {
draggable: DraggableDimension;
home: DroppableDimension;
draggables: DraggableDimensionMap;
viewport: Viewport;
}
interface Result {
afterCritical: LiftEffect;
impact: DragImpact;
}
export default ({ draggable, home, draggables, viewport }: Args): Result => {
const displacedBy: DisplacedBy = getDisplacedBy(
home.axis,
draggable.displaceBy,
);
const insideHome: DraggableDimension[] = getDraggablesInsideDroppable(
home.descriptor.id,
draggables,
);
// in a list that does not start at 0 the descriptor.index might be different from the index in the list
// eg a list could be: [2,3,4]. A descriptor.index of '2' would actually be in index '0' of the list
const rawIndex: number = insideHome.indexOf(draggable);
invariant(rawIndex !== -1, 'Expected draggable to be inside home list');
const afterDragging: DraggableDimension[] = insideHome.slice(rawIndex + 1);
const effected: DraggableIdMap = afterDragging.reduce(
(previous: DraggableIdMap, item: DraggableDimension): DraggableIdMap => {
previous[item.descriptor.id] = true;
return previous;
},
{},
);
const afterCritical: LiftEffect = {
inVirtualList: home.descriptor.mode === 'virtual',
displacedBy,
effected,
};
const displaced: DisplacementGroups = getDisplacementGroups({
afterDragging,
destination: home,
displacedBy,
last: null,
viewport: viewport.frame,
// originally we do not want any animation as we want
// everything to be fixed in the same position that
// it started in
forceShouldAnimate: false,
});
const impact: DragImpact = {
displaced,
displacedBy,
at: {
type: 'REORDER',
destination: getHomeLocation(draggable.descriptor),
},
};
return { impact, afterCritical };
};
+30
View File
@@ -0,0 +1,30 @@
import type { Position } from 'css-box-model';
import { subtract } from './position';
interface Args {
scrollHeight: number;
scrollWidth: number;
height: number;
width: number;
}
export default ({
scrollHeight,
scrollWidth,
height,
width,
}: Args): Position => {
const maxScroll: Position = subtract(
// full size
{ x: scrollWidth, y: scrollHeight },
// viewport size
{ x: width, y: height },
);
const adjustedMaxScroll: Position = {
x: Math.max(0, maxScroll.x),
y: Math.max(0, maxScroll.y),
};
return adjustedMaxScroll;
};
+16
View File
@@ -0,0 +1,16 @@
import type {
State,
DraggingState,
CollectingState,
DropPendingState,
} from '../types';
export default function isDragging(
state: State,
): state is DraggingState | CollectingState | DropPendingState {
if (state.phase === 'IDLE' || state.phase === 'DROP_ANIMATING') {
return false;
}
return state.isDragging;
}
+7
View File
@@ -0,0 +1,7 @@
import type { State, StateWhenUpdatesAllowed } from '../types';
// Using function declaration as arrow function does not play well with the %checks syntax
export default function isMovementAllowed(
state: State,
): state is StateWhenUpdatesAllowed {
return state.phase === 'DRAGGING' || state.phase === 'COLLECTING';
}
+8
View File
@@ -0,0 +1,8 @@
// is a value between two other values
export default (
lowerBound: number,
upperBound: number,
): ((a: number) => boolean) =>
(value: number): boolean =>
lowerBound <= value && value <= upperBound;
+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';

Some files were not shown because too many files have changed in this diff Show More