├── packages
├── website
│ ├── static
│ │ ├── .nojekyll
│ │ └── img
│ │ │ ├── favicon.ico
│ │ │ ├── logo.svg
│ │ │ └── undraw_docusaurus_tree.svg
│ ├── docs
│ │ └── overview.md
│ ├── sidebars.js
│ ├── babel.config.js
│ ├── .gitignore
│ ├── src
│ │ ├── pages
│ │ │ ├── SvgNote.js
│ │ │ ├── index.js
│ │ │ ├── AutoResizing.js
│ │ │ ├── styles.module.css
│ │ │ ├── pan.css
│ │ │ └── DraggableNotes.js
│ │ └── css
│ │ │ └── custom.css
│ ├── README.md
│ ├── package.json
│ └── docusaurus.config.js
├── pannable
│ ├── test
│ │ └── Pannable.test.js
│ ├── src
│ │ ├── infinite
│ │ │ ├── index.ts
│ │ │ ├── InfiniteInner.tsx
│ │ │ ├── infiniteReducer.ts
│ │ │ └── Infinite.tsx
│ │ ├── carousel
│ │ │ ├── index.ts
│ │ │ ├── Loop.tsx
│ │ │ ├── LoopInner.tsx
│ │ │ ├── CarouselInner.tsx
│ │ │ ├── loopReducer.ts
│ │ │ ├── Carousel.tsx
│ │ │ └── carouselReducer.ts
│ │ ├── utils
│ │ │ ├── hooks.ts
│ │ │ ├── resizeDetector.ts
│ │ │ ├── geometry.ts
│ │ │ ├── subscribeEvent.ts
│ │ │ ├── visible.ts
│ │ │ ├── animationFrame.ts
│ │ │ └── motion.ts
│ │ ├── index.ts
│ │ ├── pad
│ │ │ ├── index.ts
│ │ │ ├── PadContext.ts
│ │ │ ├── ItemContent.tsx
│ │ │ ├── Pad.tsx
│ │ │ ├── PadInner.tsx
│ │ │ ├── GridContent.tsx
│ │ │ └── ListContent.tsx
│ │ ├── interfaces.ts
│ │ ├── AutoResizing.tsx
│ │ ├── pannableReducer.ts
│ │ └── Pannable.tsx
│ ├── .eslintrc
│ ├── babel.config.js
│ ├── docs
│ │ ├── itemcontent.md
│ │ ├── types.md
│ │ ├── autoresizing.md
│ │ ├── player.md
│ │ ├── carousel.md
│ │ ├── infinite.md
│ │ ├── listcontent.md
│ │ ├── gridcontent.md
│ │ ├── pannable.md
│ │ └── pad.md
│ ├── tsconfig.json
│ ├── rollup.config.js
│ ├── README.md
│ └── package.json
└── demo
│ ├── .storybook
│ ├── addons.js
│ ├── webpack.config.js
│ ├── config.js
│ └── preview-head.html
│ ├── src
│ ├── stories
│ │ ├── carousel
│ │ │ ├── photo1.jpg
│ │ │ ├── photo2.jpg
│ │ │ ├── photo3.jpg
│ │ │ ├── photo4.jpg
│ │ │ ├── photo5.jpg
│ │ │ ├── SvgNext.js
│ │ │ ├── SvgPrev.js
│ │ │ ├── vi.css
│ │ │ ├── hi.css
│ │ │ ├── HorizontalIndicator.js
│ │ │ ├── VerticalIndicator.js
│ │ │ └── index.stories.js
│ │ ├── infinite
│ │ │ ├── infinite.css
│ │ │ ├── banner.css
│ │ │ ├── Banner.js
│ │ │ ├── infocard.css
│ │ │ ├── Infocard.js
│ │ │ └── index.stories.js
│ │ ├── pad
│ │ │ ├── plaid.css
│ │ │ ├── Plaid.js
│ │ │ ├── pad.css
│ │ │ ├── circle.svg
│ │ │ └── DemoText.js
│ │ ├── autoresizing
│ │ │ ├── ar.css
│ │ │ └── index.stories.js
│ │ └── pannable
│ │ │ ├── SvgNote.js
│ │ │ ├── SvgScale.js
│ │ │ ├── SvgPan.js
│ │ │ ├── SvgSticker.js
│ │ │ ├── SvgRotate.js
│ │ │ ├── pan.css
│ │ │ └── index.stories.js
│ └── ui
│ │ └── overview.css
│ ├── .gitignore
│ ├── package.json
│ └── README.md
├── .prettierrc
├── package.json
├── .gitignore
├── .editorconfig
├── LICENSE
├── .circleci
└── config.yml
└── README.md
/packages/website/static/.nojekyll:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/packages/pannable/test/Pannable.test.js:
--------------------------------------------------------------------------------
1 | test('Pannable test', () => {});
2 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "trailingComma": "es5",
3 | "singleQuote": true,
4 | "semi": true
5 | }
6 |
--------------------------------------------------------------------------------
/packages/pannable/src/infinite/index.ts:
--------------------------------------------------------------------------------
1 | export * from './infiniteReducer';
2 | export * from './Infinite';
3 |
--------------------------------------------------------------------------------
/packages/website/docs/overview.md:
--------------------------------------------------------------------------------
1 | ---
2 | id: 'overview'
3 | title: 'Overview'
4 | sidebar_label: 'Overview'
5 | ---
6 |
--------------------------------------------------------------------------------
/packages/website/sidebars.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | someSidebar: {
3 | Overview: ['overview'],
4 | },
5 | };
6 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "pannable",
3 | "private": true,
4 | "workspaces": [
5 | "packages/*"
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/packages/pannable/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "react-app",
3 | "settings": { "react": { "version": "999.999.999" } }
4 | }
--------------------------------------------------------------------------------
/packages/demo/.storybook/addons.js:
--------------------------------------------------------------------------------
1 | import '@storybook/addon-knobs/register';
2 | import '@storybook/addon-storysource/register';
3 |
--------------------------------------------------------------------------------
/packages/website/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: [require.resolve('@docusaurus/core/lib/babel/preset')],
3 | };
4 |
--------------------------------------------------------------------------------
/packages/website/static/img/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/n43/react-pannable/HEAD/packages/website/static/img/favicon.ico
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | lib
4 | es
5 | cjs
6 | types
7 | dist
8 | coverage
9 | *.log*
10 | .idea
11 | .vscode
--------------------------------------------------------------------------------
/packages/demo/src/stories/carousel/photo1.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/n43/react-pannable/HEAD/packages/demo/src/stories/carousel/photo1.jpg
--------------------------------------------------------------------------------
/packages/demo/src/stories/carousel/photo2.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/n43/react-pannable/HEAD/packages/demo/src/stories/carousel/photo2.jpg
--------------------------------------------------------------------------------
/packages/demo/src/stories/carousel/photo3.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/n43/react-pannable/HEAD/packages/demo/src/stories/carousel/photo3.jpg
--------------------------------------------------------------------------------
/packages/demo/src/stories/carousel/photo4.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/n43/react-pannable/HEAD/packages/demo/src/stories/carousel/photo4.jpg
--------------------------------------------------------------------------------
/packages/demo/src/stories/carousel/photo5.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/n43/react-pannable/HEAD/packages/demo/src/stories/carousel/photo5.jpg
--------------------------------------------------------------------------------
/packages/demo/src/stories/infinite/infinite.css:
--------------------------------------------------------------------------------
1 | .infinite-wrapper {
2 | height: 0;
3 | flex: 1;
4 | overflow: auto;
5 | --webkit-overflow-scrolling: touch;
6 | }
7 |
--------------------------------------------------------------------------------
/packages/pannable/src/carousel/index.ts:
--------------------------------------------------------------------------------
1 | export * from './loopReducer';
2 | export * from './Loop';
3 | export * from './carouselReducer';
4 | export * from './Carousel';
5 |
--------------------------------------------------------------------------------
/packages/pannable/src/utils/hooks.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useLayoutEffect } from 'react';
2 |
3 | export const useIsomorphicLayoutEffect =
4 | typeof window === 'undefined' ? useEffect : useLayoutEffect;
5 |
--------------------------------------------------------------------------------
/packages/pannable/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from './interfaces';
2 | export * from './AutoResizing';
3 | export * from './Pannable';
4 | export * from './pad';
5 | export * from './carousel';
6 | export * from './infinite';
7 |
--------------------------------------------------------------------------------
/packages/pannable/src/pad/index.ts:
--------------------------------------------------------------------------------
1 | export * from './padReducer';
2 | export * from './PadContext';
3 | export * from './Pad';
4 | export * from './ItemContent';
5 | export * from './GridContent';
6 | export * from './ListContent';
7 |
--------------------------------------------------------------------------------
/packages/demo/src/stories/infinite/banner.css:
--------------------------------------------------------------------------------
1 | .banner-wrapper {
2 | padding: 20px 0;
3 | }
4 | .banner-content {
5 | padding: 20px;
6 | background-color: #fff6ea;
7 | border-radius: 10px;
8 | font-size: 16px;
9 | line-height: 22px;
10 | text-align: center;
11 | }
12 |
--------------------------------------------------------------------------------
/packages/demo/.storybook/webpack.config.js:
--------------------------------------------------------------------------------
1 | module.exports = function({ config }) {
2 | config.module.rules.push({
3 | test: /\.stories\.jsx?$/,
4 | loaders: [require.resolve('@storybook/source-loader')],
5 | enforce: 'pre',
6 | });
7 |
8 | return config;
9 | };
10 |
--------------------------------------------------------------------------------
/packages/demo/src/stories/infinite/Banner.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import './banner.css';
3 |
4 | export default function Banner(props) {
5 | return (
6 |
9 | );
10 | }
11 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # http://editorconfig.org
2 | root = true
3 |
4 | [*]
5 | indent_style = space
6 | indent_size = 2
7 | end_of_line = lf
8 | charset = utf-8
9 | trim_trailing_whitespace = true
10 | insert_final_newline = true
11 |
12 | [*.md]
13 | trim_trailing_whitespace = false
14 |
15 | [Makefile]
16 | indent_style = tab
17 |
--------------------------------------------------------------------------------
/packages/website/.gitignore:
--------------------------------------------------------------------------------
1 | # Dependencies
2 | /node_modules
3 |
4 | # Production
5 | /build
6 |
7 | # Generated files
8 | .docusaurus
9 | .cache-loader
10 |
11 | # Misc
12 | .DS_Store
13 | .env.local
14 | .env.development.local
15 | .env.test.local
16 | .env.production.local
17 |
18 | npm-debug.log*
19 | yarn-debug.log*
20 | yarn-error.log*
21 |
--------------------------------------------------------------------------------
/packages/demo/src/stories/pad/plaid.css:
--------------------------------------------------------------------------------
1 | .plaid-data {
2 | width: 100px;
3 | height: 100px;
4 | display: flex;
5 | align-items: center;
6 | justify-content: center;
7 | font-size: 20px;
8 | line-height: 28px;
9 | color: #888888;
10 | font-weight: bold;
11 | flex: 0 0 100px;
12 | }
13 | .plaid-row {
14 | display: flex;
15 | height: 100px;
16 | }
17 |
--------------------------------------------------------------------------------
/packages/demo/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
--------------------------------------------------------------------------------
/packages/demo/src/stories/autoresizing/ar.css:
--------------------------------------------------------------------------------
1 | .ar-wrapper {
2 | height: 0;
3 | flex: 1;
4 | overflow: auto;
5 | --webkit-overflow-scrolling: touch;
6 | }
7 |
8 | .ar-box {
9 | box-sizing: border-box;
10 | background-color: #f5f5f5;
11 | border: 1px solid #cccccc;
12 | }
13 |
14 | .ar-title {
15 | padding: 16px 0;
16 | font-size: 16px;
17 | line-height: 22px;
18 | text-align: center;
19 | color: #666666;
20 | }
21 |
--------------------------------------------------------------------------------
/packages/pannable/src/utils/resizeDetector.ts:
--------------------------------------------------------------------------------
1 | import createElementResizeDetector from 'element-resize-detector';
2 |
3 | let detector: createElementResizeDetector.Erd;
4 |
5 | export function getResizeDetector() {
6 | if (typeof window === 'undefined') {
7 | return null;
8 | }
9 |
10 | if (!detector) {
11 | detector = createElementResizeDetector({ strategy: 'scroll' });
12 | }
13 |
14 | return detector;
15 | };
16 |
--------------------------------------------------------------------------------
/packages/pannable/src/utils/geometry.ts:
--------------------------------------------------------------------------------
1 | import { Size } from '../interfaces';
2 |
3 | export function isEqualToSize(
4 | s1: Size | undefined | null,
5 | s2: Size | undefined | null
6 | ): boolean {
7 | if (!s1 || !s2) {
8 | return false;
9 | }
10 | if (s1 === s2) {
11 | return true;
12 | }
13 | if (s1.width !== s2.width || s1.height !== s2.height) {
14 | return false;
15 | }
16 |
17 | return true;
18 | }
19 |
--------------------------------------------------------------------------------
/packages/demo/src/ui/overview.css:
--------------------------------------------------------------------------------
1 | .overview-wrapper {
2 | display: flex;
3 | flex-flow: column nowrap;
4 | height: 100%;
5 | padding: 16px;
6 | box-sizing: border-box;
7 | }
8 | .overview-h1 {
9 | padding: 5px 0;
10 | font-size: 20px;
11 | line-height: 28px;
12 | font-weight: bold;
13 | }
14 | .overview-desc {
15 | padding: 4px 0;
16 | font-size: 16px;
17 | line-height: 22px;
18 | }
19 | .overview-content {
20 | padding: 5px 0;
21 | }
22 |
--------------------------------------------------------------------------------
/packages/website/src/pages/SvgNote.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const SvgNote = props => (
4 |
5 |
6 |
7 |
11 |
12 |
13 | );
14 |
15 | export default SvgNote;
16 |
--------------------------------------------------------------------------------
/packages/pannable/src/pad/PadContext.ts:
--------------------------------------------------------------------------------
1 | import { Size, Rect } from '../interfaces';
2 | import React from 'react';
3 |
4 | export type PadContextType = {
5 | width?: number;
6 | height?: number;
7 | visibleRect: Rect;
8 | onResize: (size: Size) => void;
9 | };
10 |
11 | export const context = React.createContext({
12 | visibleRect: { x: 0, y: 0, width: 0, height: 0 },
13 | onResize: () => {},
14 | });
15 |
16 | export default context;
17 |
--------------------------------------------------------------------------------
/packages/demo/src/stories/pannable/SvgNote.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const SvgNote = props => (
4 |
5 |
6 |
7 |
11 |
12 |
13 | );
14 |
15 | export default SvgNote;
16 |
--------------------------------------------------------------------------------
/packages/demo/src/stories/carousel/SvgNext.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const SvgNext = props => (
4 |
5 |
9 |
10 | );
11 |
12 | export default SvgNext;
13 |
--------------------------------------------------------------------------------
/packages/demo/src/stories/carousel/SvgPrev.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const SvgPrev = props => (
4 |
5 |
9 |
10 | );
11 |
12 | export default SvgPrev;
13 |
--------------------------------------------------------------------------------
/packages/demo/src/stories/carousel/vi.css:
--------------------------------------------------------------------------------
1 | .vi-bar {
2 | position: absolute;
3 | top: 0;
4 | right: 0;
5 | bottom: 0;
6 | display: flex;
7 | flex-flow: column nowrap;
8 | justify-content: space-evenly;
9 | align-items: center;
10 | }
11 | .vi-thumb {
12 | position: relative;
13 | cursor: pointer;
14 | }
15 | .vi-thumb-mask {
16 | position: absolute;
17 | top: 0;
18 | left: 0;
19 | right: 0;
20 | bottom: 0;
21 | }
22 | .vi-thumb-active .vi-thumb-mask {
23 | background-color: rgba(0, 0, 0, 0.7);
24 | }
25 |
--------------------------------------------------------------------------------
/packages/demo/src/stories/infinite/infocard.css:
--------------------------------------------------------------------------------
1 | .infocard-wrapper {
2 | padding: 20px;
3 | background-color: #eeeeee;
4 | border-radius: 10px;
5 | }
6 | .infocard-line {
7 | margin-bottom: 7px;
8 | height: 16px;
9 | background-color: #ffffff;
10 | }
11 | .infocard-line-half {
12 | margin-bottom: 0;
13 | width: 50%;
14 | }
15 | .infocard-title {
16 | height: 28px;
17 | font-size: 20px;
18 | line-height: 28px;
19 | padding-bottom: 10px;
20 | overflow: hidden;
21 | text-overflow: ellipsis;
22 | white-space: nowrap;
23 | }
24 |
--------------------------------------------------------------------------------
/packages/website/src/pages/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Layout from '@theme/Layout';
3 | import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
4 | import DraggableNotes from './DraggableNotes';
5 |
6 | function Home() {
7 | const context = useDocusaurusContext();
8 | const { siteConfig = {} } = context;
9 | return (
10 |
14 |
15 |
16 | );
17 | }
18 |
19 | export default Home;
20 |
--------------------------------------------------------------------------------
/packages/pannable/src/utils/subscribeEvent.ts:
--------------------------------------------------------------------------------
1 | export default function subscribeEvent(
2 | target: HTMLElement,
3 | type: K,
4 | listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any,
5 | options?: boolean | AddEventListenerOptions
6 | ) {
7 | let unsubscribe = function() {};
8 |
9 | if (target && target.addEventListener) {
10 | target.addEventListener(type, listener, options);
11 |
12 | unsubscribe = function() {
13 | target.removeEventListener(type, listener, options);
14 | };
15 | }
16 |
17 | return unsubscribe;
18 | }
19 |
--------------------------------------------------------------------------------
/packages/demo/src/stories/infinite/Infocard.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import './infocard.css';
3 |
4 | export default function Infocard(props) {
5 | const { info } = props;
6 | const { title, linesOfDesc = 3 } = info;
7 | const lines = [];
8 |
9 | for (let idx = 0; idx < linesOfDesc - 1; idx++) {
10 | lines.push(
);
11 | }
12 |
13 | return (
14 |
15 |
{title}
16 | {lines}
17 |
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/packages/demo/src/stories/pannable/SvgScale.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const SvgScale = props => (
4 |
5 |
6 |
7 |
8 |
12 |
13 | );
14 |
15 | export default SvgScale;
16 |
--------------------------------------------------------------------------------
/packages/website/src/pages/AutoResizing.js:
--------------------------------------------------------------------------------
1 | import React, { useCallback } from 'react';
2 | import { AutoResizing } from 'react-pannable';
3 |
4 | const AR = () => {
5 | const onResize = useCallback(size => {
6 | console.log('Resize:', size);
7 | }, []);
8 | return (
9 |
10 | {size => {
11 | return (
12 |
19 | );
20 | }}
21 |
22 | );
23 | };
24 |
25 | export default AR;
26 |
--------------------------------------------------------------------------------
/packages/pannable/babel.config.js:
--------------------------------------------------------------------------------
1 | const { BABEL_ENV_MODULES } = process.env;
2 |
3 | module.exports = {
4 | presets: [
5 | [
6 | '@babel/env',
7 | {
8 | targets: {
9 | browsers: ['ie >= 9', 'Safari >= 8'],
10 | },
11 | modules: BABEL_ENV_MODULES || false,
12 | loose: true,
13 | },
14 | ],
15 | ],
16 | plugins: [
17 | ['@babel/plugin-proposal-class-properties', { loose: true }],
18 | '@babel/plugin-proposal-object-rest-spread',
19 | '@babel/plugin-proposal-nullish-coalescing-operator',
20 | '@babel/plugin-proposal-optional-chaining',
21 | '@babel/plugin-transform-object-assign',
22 | ],
23 | };
24 |
--------------------------------------------------------------------------------
/packages/pannable/docs/itemcontent.md:
--------------------------------------------------------------------------------
1 | # \
2 |
3 | `ItemContent` component displays data with the size best fits the specified size. In most cases, it is not used directly.
4 |
5 | ## Props
6 |
7 | ... `div` props
8 |
9 | #### `width`?: number
10 |
11 | The width of the component. If not specified, it shrinks to fit the space available.
12 |
13 | #### `height`?: number
14 |
15 | The height of the component. If not specified, it shrinks to fit the space available.
16 |
17 | #### `children`: ReactNode | (size: [Size](types.md#size--width-number-height-number-), { getResizeNode: () => ReactElement | null, calculateSize: () => void }) => ReactNode
18 |
19 | You can implement render props for the component with current size.
20 |
--------------------------------------------------------------------------------
/packages/pannable/docs/types.md:
--------------------------------------------------------------------------------
1 | ## Types
2 |
3 | #### `Size` { width: number, height: number }
4 |
5 | The dimensions with width and height.
6 |
7 | #### `Point` { x: number, y: number }
8 |
9 | A point in a 2D coordinate system.
10 |
11 | #### `Rect` { x: number, y: number, width: number, height: number }
12 |
13 | A rectangle with the location and dimensions.
14 |
15 | #### `Align` number | 'auto' | 'center' | 'start' | 'end'
16 |
17 | A position that indicate how to scroll a specific area into the visible portion.
18 |
19 | #### `Align2D` { x: [Align](#align-auto--center--start--end--number), y: [Align](#align-auto--center--start--end--number) } | [Align](#align-auto--center--start--end--number)
20 |
21 | An alignment in a 2D coordinate system.
22 |
--------------------------------------------------------------------------------
/packages/website/src/pages/styles.module.css:
--------------------------------------------------------------------------------
1 | /* stylelint-disable docusaurus/copyright-header */
2 |
3 | /**
4 | * CSS files with the .module.css suffix will be treated as CSS modules
5 | * and scoped locally.
6 | */
7 |
8 | .heroBanner {
9 | padding: 4rem 0;
10 | text-align: center;
11 | position: relative;
12 | overflow: hidden;
13 | }
14 |
15 | @media screen and (max-width: 966px) {
16 | .heroBanner {
17 | padding: 2rem;
18 | }
19 | }
20 |
21 | .buttons {
22 | display: flex;
23 | align-items: center;
24 | justify-content: center;
25 | }
26 |
27 | .features {
28 | display: flex;
29 | align-items: center;
30 | padding: 2rem 0;
31 | width: 100%;
32 | }
33 |
34 | .featureImage {
35 | height: 200px;
36 | width: 200px;
37 | }
38 |
--------------------------------------------------------------------------------
/packages/pannable/src/interfaces.ts:
--------------------------------------------------------------------------------
1 | export type Time = number;
2 | export type XY = 'x' | 'y';
3 | export type RC = 'row' | 'column';
4 | export type WH = 'width' | 'height';
5 | export type LT = 'left' | 'top';
6 | export type RB = 'right' | 'bottom';
7 | export type Align = number | 'start' | 'center' | 'end' | 'auto';
8 | export type Size = { width: number; height: number };
9 | export type Point = { x: number; y: number };
10 | export type Rect = { x: number; y: number; width: number; height: number };
11 | /* -1: 有弹性; 0: 没弹性; 1: 无边界; */
12 | export type Bound = -1 | 0 | 1;
13 | export type Inset = {
14 | top: number;
15 | right: number;
16 | bottom: number;
17 | left: number;
18 | };
19 |
20 | export interface Action {
21 | type: string;
22 | payload?: P;
23 | }
24 |
--------------------------------------------------------------------------------
/packages/demo/src/stories/pad/Plaid.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import './plaid.css';
3 |
4 | export default function Plaid(props) {
5 | const { rowCount = 0, columnCount = 0 } = props;
6 | const rows = [];
7 |
8 | for (let ridx = 0; ridx < rowCount; ridx++) {
9 | const columns = [];
10 |
11 | for (let cidx = 0; cidx < columnCount; cidx++) {
12 | const backgroundColor = (ridx + cidx) % 2 ? '#defdff' : '#cbf1ff';
13 |
14 | columns.push(
15 |
16 | {ridx} - {cidx}
17 |
18 | );
19 | }
20 |
21 | rows.push(
22 |
23 | {columns}
24 |
25 | );
26 | }
27 |
28 | return {rows}
;
29 | }
30 |
--------------------------------------------------------------------------------
/packages/demo/src/stories/carousel/hi.css:
--------------------------------------------------------------------------------
1 | .hi-prev {
2 | position: absolute;
3 | left: 0;
4 | top: 50%;
5 | margin-top: -22px;
6 | width: 44px;
7 | height: 44px;
8 | cursor: pointer;
9 | }
10 | .hi-prev:hover {
11 | opacity: 0.7;
12 | }
13 | .hi-next {
14 | position: absolute;
15 | right: 0;
16 | top: 50%;
17 | margin-top: -22px;
18 | width: 44px;
19 | height: 44px;
20 | cursor: pointer;
21 | }
22 | .hi-next:hover {
23 | opacity: 0.7;
24 | }
25 | .hi-bar {
26 | position: absolute;
27 | bottom: 18px;
28 | left: 0;
29 | right: 0;
30 | display: flex;
31 | justify-content: center;
32 | }
33 | .hi-dot {
34 | margin: 0 4px;
35 | width: 8px;
36 | height: 8px;
37 | border-radius: 4px;
38 | background-color: #ffffff;
39 | cursor: pointer;
40 | }
41 | .hi-dot-active {
42 | background-color: #75d3ec;
43 | }
44 |
--------------------------------------------------------------------------------
/packages/demo/src/stories/pannable/SvgPan.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const SvgPan = props => (
4 |
5 |
6 |
7 |
8 |
12 |
13 | );
14 |
15 | export default SvgPan;
16 |
--------------------------------------------------------------------------------
/packages/pannable/src/utils/visible.ts:
--------------------------------------------------------------------------------
1 | import { XY, Rect } from '../interfaces';
2 |
3 | export function getItemVisibleRect(rect: Rect, vRect: Rect): Rect {
4 | function calculate(x: XY) {
5 | const width = x === 'x' ? 'width' : 'height';
6 |
7 | return { [x]: vRect[x] - rect[x], [width]: vRect[width] };
8 | }
9 |
10 | const { x, width } = calculate('x');
11 | const { y, height } = calculate('y');
12 |
13 | return { x, y, width, height };
14 | }
15 |
16 | export function needsRender(rect: Rect, vRect: Rect): boolean {
17 | if (!vRect) {
18 | return true;
19 | }
20 |
21 | function calculate(x: XY) {
22 | const width = x === 'x' ? 'width' : 'height';
23 |
24 | return (
25 | vRect[x] - vRect[width] / 4 <= rect[x] + rect[width] &&
26 | rect[x] <= vRect[x] + (vRect[width] * 5) / 4
27 | );
28 | }
29 |
30 | return calculate('x') && calculate('y');
31 | }
32 |
--------------------------------------------------------------------------------
/packages/website/README.md:
--------------------------------------------------------------------------------
1 | # Website
2 |
3 | This website is built using [Docusaurus 2](https://v2.docusaurus.io/), a modern static website generator.
4 |
5 | ## Installation
6 |
7 | ```console
8 | yarn install
9 | ```
10 |
11 | ## Local Development
12 |
13 | ```console
14 | yarn start
15 | ```
16 |
17 | This command starts a local development server and open up a browser window. Most changes are reflected live without having to restart the server.
18 |
19 | ## Build
20 |
21 | ```console
22 | yarn build
23 | ```
24 |
25 | This command generates static content into the `build` directory and can be served using any static contents hosting service.
26 |
27 | ## Deployment
28 |
29 | ```console
30 | GIT_USER= USE_SSH=true yarn deploy
31 | ```
32 |
33 | If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch.
34 |
--------------------------------------------------------------------------------
/packages/website/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "website",
3 | "version": "0.0.0",
4 | "private": true,
5 | "scripts": {
6 | "docusaurus": "docusaurus",
7 | "start": "docusaurus start",
8 | "build": "docusaurus build",
9 | "swizzle": "docusaurus swizzle",
10 | "deploy": "docusaurus deploy",
11 | "serve": "docusaurus serve"
12 | },
13 | "dependencies": {
14 | "@docusaurus/core": "2.0.0-alpha.66",
15 | "@docusaurus/preset-classic": "2.0.0-alpha.66",
16 | "@mdx-js/react": "^1.5.8",
17 | "clsx": "^1.1.1",
18 | "react": "^16.8.4",
19 | "react-dom": "^16.8.4",
20 | "react-pannable": "^5.0.0"
21 | },
22 | "browserslist": {
23 | "production": [
24 | ">0.2%",
25 | "not dead",
26 | "not op_mini all"
27 | ],
28 | "development": [
29 | "last 1 chrome version",
30 | "last 1 firefox version",
31 | "last 1 safari version"
32 | ]
33 | }
34 | }
--------------------------------------------------------------------------------
/packages/demo/src/stories/pad/pad.css:
--------------------------------------------------------------------------------
1 | .pad-content {
2 | background-color: #dddddd;
3 | }
4 | .pad-intro {
5 | padding: 20px;
6 | font-size: 20px;
7 | line-height: 28px;
8 | color: #666666;
9 | white-space: pre-line;
10 | background-color: #eeeeee;
11 | }
12 | .pad-circle {
13 | position: absolute;
14 | width: 100%;
15 | height: 100%;
16 | }
17 | .pad-griditem {
18 | display: flex;
19 | align-items: center;
20 | justify-content: center;
21 | width: 100%;
22 | height: 100%;
23 | font-size: 20px;
24 | line-height: 28px;
25 | color: #333333;
26 | font-weight: bold;
27 | }
28 | .pad-listitem {
29 | display: flex;
30 | align-items: center;
31 | justify-content: center;
32 | box-sizing: border-box;
33 | padding: 0 30px;
34 | font-size: 20px;
35 | line-height: 28px;
36 | color: #ffffff;
37 | font-weight: bold;
38 | white-space: nowrap;
39 | background-color: #333333;
40 | border: 3px solid #ffffff;
41 | }
42 |
--------------------------------------------------------------------------------
/packages/website/src/css/custom.css:
--------------------------------------------------------------------------------
1 | /* stylelint-disable docusaurus/copyright-header */
2 | /**
3 | * Any CSS included here will be global. The classic template
4 | * bundles Infima by default. Infima is a CSS framework designed to
5 | * work well for content-centric websites.
6 | */
7 |
8 | /* You can override the default Infima variables here. */
9 | :root {
10 | --ifm-color-primary: #25c2a0;
11 | --ifm-color-primary-dark: rgb(33, 175, 144);
12 | --ifm-color-primary-darker: rgb(31, 165, 136);
13 | --ifm-color-primary-darkest: rgb(26, 136, 112);
14 | --ifm-color-primary-light: rgb(70, 203, 174);
15 | --ifm-color-primary-lighter: rgb(102, 212, 189);
16 | --ifm-color-primary-lightest: rgb(146, 224, 208);
17 | --ifm-code-font-size: 95%;
18 | }
19 |
20 | .docusaurus-highlight-code-line {
21 | background-color: rgb(72, 77, 91);
22 | display: block;
23 | margin: 0 calc(-1 * var(--ifm-pre-padding));
24 | padding: 0 var(--ifm-pre-padding);
25 | }
26 |
--------------------------------------------------------------------------------
/packages/demo/src/stories/pad/circle.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/packages/pannable/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "esnext",
4 | "module": "esnext",
5 | "lib": ["esnext", "dom"],
6 | "outDir": "lib",
7 | "declaration": true,
8 | "declarationDir": "types",
9 | "declarationMap": true,
10 |
11 | /* Strict Type-Checking Options */
12 | "strict": true,
13 | "strictNullChecks": true,
14 | "strictFunctionTypes": true,
15 | "strictBindCallApply": true,
16 | "strictPropertyInitialization": true,
17 | "noImplicitThis": true,
18 | "alwaysStrict": true,
19 |
20 | /* Additional Checks */
21 | "noImplicitReturns": true,
22 | "noFallthroughCasesInSwitch": true,
23 |
24 | /* Module Resolution Options */
25 | "moduleResolution": "node",
26 | "allowSyntheticDefaultImports": true,
27 | "esModuleInterop": true,
28 | "baseUrl": "src",
29 |
30 | /* Advanced Options */
31 | "resolveJsonModule": true,
32 | "jsx": "react"
33 | },
34 | "include": ["src"]
35 | }
36 |
--------------------------------------------------------------------------------
/packages/demo/.storybook/config.js:
--------------------------------------------------------------------------------
1 | import { configure, addParameters } from '@storybook/react';
2 | import 'normalize.css';
3 |
4 | addParameters({
5 | options: {
6 | showNav: true,
7 | isToolshown: false,
8 | showPanel: true,
9 | panelPosition: 'right',
10 | theme: {
11 | brandTitle: 'react-pannable',
12 | brandUrl: 'https://github.com/n43/react-pannable',
13 | },
14 | storySort(s1, s2) {
15 | return convertOrderFromStory(s1) < convertOrderFromStory(s2);
16 | },
17 | },
18 | });
19 |
20 | configure(require.context('../src/stories', true, /\.stories\.js$/), module);
21 |
22 | function convertOrderFromStory(story) {
23 | const sid = story[0] || '';
24 |
25 | if (sid.indexOf('carousel-') === 0) {
26 | return 4;
27 | }
28 | if (sid.indexOf('infinite-') === 0) {
29 | return 3;
30 | }
31 | if (sid.indexOf('pad-') === 0) {
32 | return 2;
33 | }
34 | if (sid.indexOf('pannable-') === 0) {
35 | return 1;
36 | }
37 |
38 | return 0;
39 | }
40 |
--------------------------------------------------------------------------------
/packages/demo/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-pannable-demo",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "clsx": "^1.1.1",
7 | "normalize.css": "^8.0.1",
8 | "react": "^16.8.4",
9 | "react-dom": "^16.8.4",
10 | "react-pannable": "^6.0.1"
11 | },
12 | "devDependencies": {
13 | "@babel/core": "^7.15.0",
14 | "@storybook/addon-knobs": "6.1.17",
15 | "@storybook/addon-storysource": "6.1.17",
16 | "@storybook/react": "6.1.17",
17 | "@storybook/storybook-deployer": "2.8.10",
18 | "babel-loader": "^8.1.0",
19 | "webpack": "^5.50.0"
20 | },
21 | "scripts": {
22 | "start": "start-storybook -p 9001 -c .storybook",
23 | "build": "rm -rf ./dist & build-storybook -c .storybook -o dist",
24 | "deploy": "yarn build && storybook-to-ghpages -- --existing-output-dir=dist"
25 | },
26 | "eslintConfig": {
27 | "extends": "react-app"
28 | },
29 | "browserslist": [
30 | ">0.2%",
31 | "not dead",
32 | "not ie <= 11",
33 | "not op_mini all"
34 | ]
35 | }
36 |
--------------------------------------------------------------------------------
/packages/website/docusaurus.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | title: 'My Site',
3 | tagline: 'The tagline of my site',
4 | url: 'https://your-docusaurus-test-site.com',
5 | baseUrl: '/',
6 | onBrokenLinks: 'throw',
7 | favicon: 'img/favicon.ico',
8 | organizationName: 'facebook', // Usually your GitHub org/user name.
9 | projectName: 'docusaurus', // Usually your repo name.
10 | themeConfig: {
11 | navbar: {
12 | title: 'My Site',
13 | logo: {
14 | alt: 'My Site Logo',
15 | src: 'img/logo.svg',
16 | },
17 | items: [],
18 | },
19 | footer: {
20 | style: 'dark',
21 | links: [],
22 | copyright: `Copyright © ${new Date().getFullYear()} My Project, Inc. Built with Docusaurus.`,
23 | },
24 | },
25 | presets: [
26 | [
27 | '@docusaurus/preset-classic',
28 | {
29 | docs: {
30 | sidebarPath: require.resolve('./sidebars.js'),
31 | },
32 | theme: {
33 | customCss: require.resolve('./src/css/custom.css'),
34 | },
35 | },
36 | ],
37 | ],
38 | };
39 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/packages/demo/src/stories/pannable/SvgSticker.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const SvgSticker = props => (
4 |
5 |
6 |
7 |
8 |
9 |
10 | );
11 |
12 | export default SvgSticker;
13 |
--------------------------------------------------------------------------------
/packages/demo/src/stories/carousel/HorizontalIndicator.js:
--------------------------------------------------------------------------------
1 | import React, { Fragment, useCallback } from 'react';
2 | import clsx from 'clsx';
3 | import SvgPrev from './SvgPrev';
4 | import SvgNext from './SvgNext';
5 | import './hi.css';
6 |
7 | export default function HorizontalIndicator(props) {
8 | const { activeIndex, itemCount, onPrev, onNext, onGoto } = props;
9 | const dots = [];
10 |
11 | const onDotClick = useCallback(
12 | evt => {
13 | const node = evt.currentTarget;
14 | const index = Number(node.dataset.index);
15 |
16 | if (onGoto) {
17 | onGoto(index);
18 | }
19 | },
20 | [onGoto]
21 | );
22 |
23 | for (let idx = 0; idx < itemCount; idx++) {
24 | dots.push(
25 |
31 | );
32 | }
33 |
34 | return (
35 |
36 | {dots}
37 |
38 |
39 |
40 | );
41 | }
42 |
--------------------------------------------------------------------------------
/packages/demo/src/stories/pannable/SvgRotate.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const SvgRotate = props => (
4 |
5 |
6 |
7 |
8 |
12 |
13 | );
14 |
15 | export default SvgRotate;
16 |
--------------------------------------------------------------------------------
/packages/demo/.storybook/preview-head.html:
--------------------------------------------------------------------------------
1 |
33 |
48 |
--------------------------------------------------------------------------------
/packages/pannable/src/carousel/Loop.tsx:
--------------------------------------------------------------------------------
1 | import { LoopState } from './loopReducer';
2 | import LoopInner from './LoopInner';
3 | import { XY } from '../interfaces';
4 | import { PadState, PadMethods } from '../pad/padReducer';
5 | import Pad from '../pad/Pad';
6 | import React from 'react';
7 |
8 | export interface LoopProps {
9 | direction: XY;
10 | render?: (state: LoopState) => React.ReactNode;
11 | }
12 |
13 | export const Loop = React.memo<
14 | Omit, 'render'> & LoopProps
15 | >((props) => {
16 | const { direction = 'x', render, children, ...padProps } = props;
17 | const { directionalLockEnabled = true } = padProps;
18 |
19 | padProps.directionalLockEnabled = directionalLockEnabled;
20 |
21 | if (direction === 'x') {
22 | padProps.boundX = padProps.boundX ?? -1;
23 | } else {
24 | padProps.boundY = padProps.boundY ?? -1;
25 | }
26 |
27 | return (
28 | (
31 | {
36 | return render ? render(state) : children;
37 | }}
38 | />
39 | )}
40 | />
41 | );
42 | });
43 |
44 | export default Loop;
45 |
--------------------------------------------------------------------------------
/.circleci/config.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | jobs:
3 | build:
4 | docker:
5 | - image: circleci/node:latest
6 | steps:
7 | - checkout
8 | - restore_cache:
9 | keys:
10 | - dependencies-{{ checksum "yarn.lock" }}
11 | # fallback to using the latest cache if no exact match is found
12 | - dependencies-
13 | - run:
14 | name: Install
15 | command: yarn install
16 | - save_cache:
17 | paths:
18 | - node_modules
19 | key: dependencies-{{ checksum "yarn.lock" }}
20 | test:
21 | docker:
22 | - image: circleci/node:latest
23 | steps:
24 | - checkout
25 | - restore_cache:
26 | keys:
27 | - dependencies-{{ checksum "yarn.lock" }}
28 | # fallback to using the latest cache if no exact match is found
29 | - dependencies-
30 | - run:
31 | name: Install
32 | command: yarn install
33 | - save_cache:
34 | paths:
35 | - node_modules
36 | key: dependencies-{{ checksum "yarn.lock" }}
37 | # - run:
38 | # name: ESLint
39 | # command: yarn lint
40 | workflows:
41 | version: 2
42 | build_and_test:
43 | jobs:
44 | - build
45 | - test:
46 | requires:
47 | - build
48 | filters:
49 | branches:
50 | ignore: gh-pages
51 |
--------------------------------------------------------------------------------
/packages/pannable/src/utils/animationFrame.ts:
--------------------------------------------------------------------------------
1 | interface IndexableWindow {
2 | [key: string]: any;
3 | }
4 |
5 | let requestAnimationFrame: (callback: FrameRequestCallback) => number;
6 | let cancelAnimationFrame: (handle: number) => void;
7 |
8 | if (typeof window !== 'undefined') {
9 | requestAnimationFrame = window.requestAnimationFrame;
10 | cancelAnimationFrame = window.cancelAnimationFrame;
11 |
12 | const vendors = ['ms', 'moz', 'webkit', 'o'];
13 | const win = window as IndexableWindow;
14 | let idx = 0;
15 |
16 | while (!requestAnimationFrame && idx < vendors.length) {
17 | requestAnimationFrame = win[vendors[idx] + 'RequestAnimationFrame'];
18 | cancelAnimationFrame =
19 | win[vendors[idx] + 'CancelAnimationFrame'] ||
20 | win[vendors[idx] + 'CancelRequestAnimationFrame'];
21 | idx++;
22 | }
23 |
24 | if (!requestAnimationFrame) {
25 | let lastTime = 0;
26 |
27 | requestAnimationFrame = function(callback) {
28 | const currTime = new Date().getTime();
29 | const timeToCall = Math.max(0, 16 - (currTime - lastTime));
30 |
31 | const id = window.setTimeout(function() {
32 | callback(currTime + timeToCall);
33 | }, timeToCall);
34 |
35 | lastTime = currTime + timeToCall;
36 | return id;
37 | };
38 |
39 | cancelAnimationFrame = function(id) {
40 | window.clearTimeout(id);
41 | };
42 | }
43 | }
44 |
45 | export { requestAnimationFrame, cancelAnimationFrame };
46 |
--------------------------------------------------------------------------------
/packages/pannable/docs/autoresizing.md:
--------------------------------------------------------------------------------
1 | # \
2 |
3 | `AutoResizing` calculates the size of the component automatically when its parent node's size changes.
4 |
5 | ## Usage
6 |
7 | ```js
8 | import React from 'react';
9 | import { AutoResizing, Pad } from 'react-pannable';
10 |
11 | class Page extends React.Component {
12 | render() {
13 | return (
14 |
15 |
16 | {size => (
17 |
18 | Some thing...
19 |
20 | )}
21 |
22 |
23 | );
24 | }
25 | }
26 | ```
27 |
28 | [](https://codepen.io/cztflove/pen/MRzPXw)
29 |
30 | ## Props
31 |
32 | #### `children`: ReactNode | (size: [Size](types.md#size--width-number-height-number-)) => ReactNode
33 |
34 | You can implement render props for the component with current size.
35 |
36 | #### `width`?: number
37 |
38 | the width of the component. If not specified, it grows to fit the space available.
39 |
40 | #### `height`?: number
41 |
42 | the height of the component. If not specified, it grows to fit the space available.
43 |
44 | #### `onResize`?: (size: [Size](types.md#size--width-number-height-number-)) => void
45 |
46 | Calls when changes the size of the component.
47 |
--------------------------------------------------------------------------------
/packages/demo/src/stories/carousel/VerticalIndicator.js:
--------------------------------------------------------------------------------
1 | import React, { Fragment, useCallback } from 'react';
2 | import clsx from 'clsx';
3 | import './vi.css';
4 |
5 | export default function VerticalIndicator(props) {
6 | const { activeIndex, list, width, height, onGoto } = props;
7 | const itemCount = list.length;
8 |
9 | const onThumbClick = useCallback(
10 | evt => {
11 | const node = evt.currentTarget;
12 | const index = Number(node.dataset.index);
13 |
14 | if (onGoto) {
15 | onGoto(index);
16 | }
17 | },
18 | [onGoto]
19 | );
20 |
21 | if (itemCount < 2) {
22 | return null;
23 | }
24 |
25 | const thumbs = [];
26 |
27 | const barWidth = Math.floor(width / itemCount);
28 | const thumbWidth = Math.floor(0.9 * barWidth);
29 | const thumbHeight = Math.round(thumbWidth * (height / width));
30 |
31 | for (let idx = 0; idx < itemCount; idx++) {
32 | thumbs.push(
33 |
46 | );
47 | }
48 |
49 | return (
50 |
51 | {thumbs}
52 |
53 | );
54 | }
55 |
--------------------------------------------------------------------------------
/packages/pannable/docs/player.md:
--------------------------------------------------------------------------------
1 | # \
2 |
3 | `Player` component used to manage the playback of the paging content.
4 |
5 | ## Usage
6 |
7 | ```js
8 | import React from 'react';
9 | import { Player, GridContent } from 'react-pannable';
10 |
11 | class Page extends React.Component {
12 | render() {
13 | return (
14 |
21 | {
29 | const style = {
30 | backgroundColor: itemIndex % 2 ? '#defdff' : '#cbf1ff',
31 | };
32 |
33 | return
;
34 | }}
35 | />
36 |
37 | );
38 | }
39 | }
40 | ```
41 |
42 | [](https://codepen.io/cztflove/pen/qwvNLp)
43 |
44 | ## Props
45 |
46 | ... [`Pad`](pad.md) props
47 |
48 | #### `direction`?: 'x' | 'y'
49 |
50 | The scroll direction of the player. The default value is `x`.
51 |
52 | #### `loop`?: boolean
53 |
54 | Determines whether the player should loop indefinitely. The default value is `true`
55 |
56 | #### `autoplayEnabled`?: boolean
57 |
58 | Determines whether the player should automatically playback. The default value is `true`
59 |
60 | #### `autoplayInterval`?: number
61 |
62 | Delay between transitions (in ms). The default value is `5000`
63 |
--------------------------------------------------------------------------------
/packages/website/src/pages/pan.css:
--------------------------------------------------------------------------------
1 | .pan-wrapper {
2 | position: relative;
3 | background-color: #fff6ea;
4 | }
5 | .pan-note {
6 | position: absolute;
7 | top: 0;
8 | left: 0;
9 | width: 200px;
10 | height: 200px;
11 | transition: transform 0.3s ease;
12 | }
13 | .pan-note-dragging {
14 | z-index: 10;
15 | transition-duration: 0s;
16 | }
17 | .pan-note-bg {
18 | position: absolute;
19 | top: 0;
20 | left: 0;
21 | right: 0;
22 | bottom: 0;
23 | }
24 | .pan-note-content {
25 | position: absolute;
26 | top: 0;
27 | left: 0;
28 | right: 0;
29 | padding: 10px;
30 | }
31 | .pan-note-desc {
32 | font-size: 14px;
33 | line-height: 20px;
34 | }
35 | .pan-note-trigger {
36 | margin-bottom: 10px;
37 | padding: 5px;
38 | font-size: 14px;
39 | line-height: 20px;
40 | font-weight: bold;
41 | text-align: center;
42 | background-color: #ecca3e;
43 | }
44 |
45 | .pan-sticker {
46 | position: relative;
47 | }
48 | .pan-sticker-dragging {
49 | border: 1px dashed #cccccc;
50 | }
51 | .pan-sticker-image {
52 | position: absolute;
53 | width: 100%;
54 | height: 100%;
55 | }
56 | .pan-sticker-translate {
57 | position: absolute;
58 | left: 2px;
59 | top: 2px;
60 | width: 30px;
61 | height: 30px;
62 | }
63 | .pan-sticker-scale {
64 | position: absolute;
65 | right: 2px;
66 | bottom: 2px;
67 | width: 30px;
68 | height: 30px;
69 | }
70 | .pan-sticker-rotate {
71 | position: absolute;
72 | left: 2px;
73 | bottom: 2px;
74 | width: 30px;
75 | height: 30px;
76 | }
77 | .pan-sticker-edit {
78 | position: absolute;
79 | right: 2px;
80 | top: 2px;
81 | padding: 0 4px;
82 | color: #ffffff;
83 | font-size: 20px;
84 | line-height: 30px;
85 | }
86 |
--------------------------------------------------------------------------------
/packages/demo/src/stories/pannable/pan.css:
--------------------------------------------------------------------------------
1 | .pan-wrapper {
2 | position: relative;
3 | background-color: #fff6ea;
4 | }
5 | .pan-note {
6 | position: absolute;
7 | top: 0;
8 | left: 0;
9 | width: 200px;
10 | height: 200px;
11 | transition: transform 0.3s ease;
12 | }
13 | .pan-note-dragging {
14 | z-index: 10;
15 | transition-duration: 0s;
16 | }
17 | .pan-note-bg {
18 | position: absolute;
19 | top: 0;
20 | left: 0;
21 | right: 0;
22 | bottom: 0;
23 | }
24 | .pan-note-content {
25 | position: absolute;
26 | top: 0;
27 | left: 0;
28 | right: 0;
29 | padding: 10px;
30 | }
31 | .pan-note-desc {
32 | font-size: 14px;
33 | line-height: 20px;
34 | }
35 | .pan-note-trigger {
36 | margin-bottom: 10px;
37 | padding: 5px;
38 | font-size: 14px;
39 | line-height: 20px;
40 | font-weight: bold;
41 | text-align: center;
42 | background-color: #ecca3e;
43 | }
44 |
45 | .pan-sticker {
46 | position: relative;
47 | }
48 | .pan-sticker-dragging {
49 | border: 1px dashed #cccccc;
50 | }
51 | .pan-sticker-image {
52 | position: absolute;
53 | width: 100%;
54 | height: 100%;
55 | }
56 | .pan-sticker-translate {
57 | position: absolute;
58 | left: 2px;
59 | top: 2px;
60 | width: 30px;
61 | height: 30px;
62 | }
63 | .pan-sticker-scale {
64 | position: absolute;
65 | right: 2px;
66 | bottom: 2px;
67 | width: 30px;
68 | height: 30px;
69 | }
70 | .pan-sticker-rotate {
71 | position: absolute;
72 | left: 2px;
73 | bottom: 2px;
74 | width: 30px;
75 | height: 30px;
76 | }
77 | .pan-sticker-edit {
78 | position: absolute;
79 | right: 2px;
80 | top: 2px;
81 | padding: 0 4px;
82 | color: #ffffff;
83 | font-size: 20px;
84 | line-height: 30px;
85 | }
86 |
--------------------------------------------------------------------------------
/packages/pannable/rollup.config.js:
--------------------------------------------------------------------------------
1 | import nodeResolve from '@rollup/plugin-node-resolve';
2 | import commonjs from '@rollup/plugin-commonjs';
3 | import replace from '@rollup/plugin-replace';
4 | import { terser } from 'rollup-plugin-terser';
5 |
6 | const inputFile = {
7 | index: 'es/index.js',
8 | };
9 |
10 | export default [
11 | // UMD Development
12 | {
13 | input: inputFile,
14 | output: {
15 | dir: 'dist',
16 | entryFileNames: '[name].js',
17 | format: 'umd',
18 | name: 'ReactPannable',
19 | globals: {
20 | react: 'React',
21 | 'react-dom': 'ReactDOM',
22 | },
23 | },
24 | external: ['react', 'react-dom'],
25 | plugins: [
26 | nodeResolve({ browser: true }),
27 | commonjs(),
28 | replace({
29 | 'process.env.NODE_ENV': JSON.stringify('development'),
30 | preventAssignment: true,
31 | }),
32 | ],
33 | },
34 | // UMD Production
35 | {
36 | input: inputFile,
37 | output: {
38 | dir: 'dist',
39 | entryFileNames: '[name].min.js',
40 | format: 'umd',
41 | name: 'ReactPannable',
42 | globals: {
43 | react: 'React',
44 | 'react-dom': 'ReactDOM',
45 | },
46 | },
47 | external: ['react', 'react-dom'],
48 | plugins: [
49 | nodeResolve({ browser: true }),
50 | commonjs(),
51 | replace({
52 | 'process.env.NODE_ENV': JSON.stringify('production'),
53 | preventAssignment: true,
54 | }),
55 | terser({
56 | compress: {
57 | pure_getters: true,
58 | unsafe: true,
59 | unsafe_comps: true,
60 | warnings: false,
61 | },
62 | }),
63 | ],
64 | },
65 | ];
66 |
--------------------------------------------------------------------------------
/packages/demo/src/stories/autoresizing/index.stories.js:
--------------------------------------------------------------------------------
1 | import React, { useCallback } from 'react';
2 | import { withKnobs, select, object } from '@storybook/addon-knobs';
3 | import { AutoResizing } from 'react-pannable';
4 | import '../../ui/overview.css';
5 | import './ar.css';
6 |
7 | export default {
8 | title: 'AutoResizing',
9 | decorators: [withKnobs],
10 | };
11 |
12 | export const Overview = () => {
13 | const arWidth = select(
14 | 'width',
15 | { undefined: undefined, '400': 400, '600': 600 },
16 | undefined,
17 | 'props'
18 | );
19 | const arHeight = select(
20 | 'height',
21 | { undefined: undefined, '400': 400, '600': 600 },
22 | undefined,
23 | 'props'
24 | );
25 | const wrapperStyle = object('Wrapper Style', { paddingTop: 20 });
26 |
27 | const onResize = useCallback((size) => {
28 | console.log('onResize', size);
29 | }, []);
30 |
31 | return (
32 |
33 |
AutoResizing
34 |
35 | AutoResizing component calculates the size of the component
36 | automatically when its parent node's size changes.
37 |
38 |
39 |
(
44 |
45 |
46 | width: {width} ; height: {height} ;
47 |
48 |
49 | )}
50 | />
51 |
52 |
53 | );
54 | };
55 |
--------------------------------------------------------------------------------
/packages/pannable/src/carousel/LoopInner.tsx:
--------------------------------------------------------------------------------
1 | import reducer, { initialLoopState, LoopState } from './loopReducer';
2 | import { PadState, PadMethods } from '../pad/padReducer';
3 | import ListContent from '../pad/ListContent';
4 | import { XY } from '../interfaces';
5 | import { useIsomorphicLayoutEffect } from '../utils/hooks';
6 | import React, { useReducer, useRef } from 'react';
7 |
8 | export interface LoopInnerProps {
9 | pad: PadState;
10 | padMethods: PadMethods;
11 | direction: XY;
12 | render: (state: LoopState) => React.ReactNode;
13 | }
14 |
15 | export const LoopInner = React.memo((props) => {
16 | const { pad, padMethods, direction, render } = props;
17 | const [state, dispatch] = useReducer(reducer, initialLoopState);
18 | const delegate = { scrollTo: padMethods.scrollTo };
19 | const delegateRef = useRef(delegate);
20 | delegateRef.current = delegate;
21 |
22 | useIsomorphicLayoutEffect(() => {
23 | dispatch({ type: 'setState', payload: { pad } });
24 | }, [pad]);
25 |
26 | useIsomorphicLayoutEffect(() => {
27 | dispatch({ type: 'setState', payload: { direction } });
28 | }, [direction]);
29 |
30 | useIsomorphicLayoutEffect(() => {
31 | if (state.scrollTo) {
32 | delegateRef.current.scrollTo(state.scrollTo);
33 | }
34 | }, [state.scrollTo]);
35 |
36 | return (
37 | {
43 | return (
44 | -
45 | {render(state)}
46 |
47 | );
48 | }}
49 | />
50 | );
51 | });
52 |
53 | export default LoopInner;
54 |
--------------------------------------------------------------------------------
/packages/pannable/docs/carousel.md:
--------------------------------------------------------------------------------
1 | # \
2 |
3 | `Carousel` component used to play a number of looping items in sequence.
4 |
5 | ## Usage
6 |
7 | ```js
8 | import React from 'react';
9 | import { Carousel } from 'react-pannable';
10 |
11 | class Page extends React.Component {
12 | render() {
13 | return (
14 | {
22 | const style = {
23 | height: '100%',
24 | backgroundColor: itemIndex % 2 ? '#defdff' : '#cbf1ff',
25 | };
26 |
27 | return
;
28 | }}
29 | >
30 | {({ activeIndex }) => {activeIndex}
}
31 |
32 | );
33 | }
34 | }
35 | ```
36 |
37 | [](https://codepen.io/cztflove/pen/JVVoma)
38 |
39 | ## Props
40 |
41 | #### `itemCount`: number
42 |
43 | The number of items.
44 |
45 | #### `renderItem`: (attrs: [GridItemAttrs](gridcontent.md#girditemattrs--itemindex-number-rowindex-number-columnindex-number-rect-rect-visiblerect-rect-needsrender-boolean-item-componentitemprops-any-)) => ReactNode
46 |
47 | Returns the React element that corresponds to the specified item.
48 |
49 | #### `onActiveIndexChange`?: (attrs: [CarouselAttrs](#carouselattrs--itemcount-number-activeindex-number-)) => void
50 |
51 | Calls when the active index changes.
52 |
53 | #### `scrollToIndex`?: { index?: number | (attrs: [CarouselAttrs](#carouselattrs--itemcount-number-activeindex-number-)) => number, animated: boolean }
54 |
55 | Scrolls to the specified index of item.
56 |
57 | #### `children`?: ReactNode | (attrs: [CarouselAttrs](#carouselattrs--itemcount-number-activeindex-number-)) => ReactNode
58 |
59 | You can add some controls with active index.
60 |
61 | ## Types
62 |
63 | #### CarouselAttrs { itemCount: number, activeIndex: number };
64 |
--------------------------------------------------------------------------------
/packages/pannable/docs/infinite.md:
--------------------------------------------------------------------------------
1 | # \
2 |
3 | `Infinite` component used to display a long list of data.
4 |
5 | ## Usage
6 |
7 | ```js
8 | import React from 'react';
9 | import { Infinite } from 'react-pannable';
10 |
11 | class Page extends React.Component {
12 | render() {
13 | return (
14 | {
19 | const style = {
20 | height: `${20 * (itemIndex + 1)}px`,
21 | };
22 |
23 | return {itemIndex}
;
24 | }}
25 | />
26 | );
27 | }
28 | }
29 | ```
30 |
31 | ## Props
32 |
33 | ... [`Pad`](pad.md#props) props
34 |
35 | #### `direction`?: 'x' | 'y'
36 |
37 | The layout direction of the content. The default value is `y`.
38 |
39 | #### `spacing`?: number
40 |
41 | The minimum spacing to use between items.
42 |
43 | #### `itemCount`: number
44 |
45 | The number of items.
46 |
47 | #### `estimatedItemWidth`?: number | (itemIndex: number) => number
48 |
49 | The estimated width of items.
50 |
51 | #### `estimatedItemHeight`?: number | (itemIndex: number) => number
52 |
53 | The estimated height of items.
54 |
55 | #### `renderItem`: (attrs: [ListItemAttrs](listcontent.md#listitemattrs--itemindex-number-rect-rect-visiblerect-rect-needsrender-boolean-item-componentitemprops-any-)) => ReactNode
56 |
57 | Returns the React element that corresponds to the specified item.
58 |
59 | #### `renderHeader`: (attrs: [ListItemAttrs](listcontent.md#listitemattrs--itemindex-number-rect-rect-visiblerect-rect-needsrender-boolean-item-componentitemprops-any-)) => ReactNode
60 |
61 | Returns the React element that corresponds to the header.
62 |
63 | #### `renderFooter`: (attrs: [ListItemAttrs](listcontent.md#listitemattrs--itemindex-number-rect-rect-visiblerect-rect-needsrender-boolean-item-componentitemprops-any-)) => ReactNode
64 |
65 | Returns the React element that corresponds to the footer.
66 |
67 | #### `scrollToIndex`?: { index?: number, align?: [Align2D](types.md#align2d--x-align-y-align---align), animated?: boolean }
68 |
69 | Scrolls to the specified index of item.
70 |
--------------------------------------------------------------------------------
/packages/pannable/docs/listcontent.md:
--------------------------------------------------------------------------------
1 | # \
2 |
3 | `ListContent` component displays data in a single column/row. It provides the items that display the actual content.
4 |
5 | ## Usage
6 |
7 | ```js
8 | import React from 'react';
9 | import { Pad, ListContent } from 'react-pannable';
10 |
11 | class Page extends React.Component {
12 | render() {
13 | return (
14 |
15 | {itemIndex}
}
19 | />
20 |
21 | );
22 | }
23 | }
24 | ```
25 |
26 | [](https://codepen.io/cztflove/pen/yrrNOv)
27 |
28 | ## Props
29 |
30 | #### `width`?: number
31 |
32 | The width of the component. If not specified, it shrinks to fit the space available.
33 |
34 | #### `height`?: number
35 |
36 | The height of the component. If not specified, it shrinks to fit the space available.
37 |
38 | #### `direction`?: 'x' | 'y'
39 |
40 | The layout direction of the content. The default value is `y`.
41 |
42 | #### `spacing`?: number
43 |
44 | The minimum spacing to use between items.
45 |
46 | #### `itemCount`: number
47 |
48 | The number of items.
49 |
50 | #### `estimatedItemWidth`?: number | (itemIndex: number) => number
51 |
52 | The estimated width of items.
53 |
54 | #### `estimatedItemHeight`?: number | (itemIndex: number) => number
55 |
56 | The estimated height of items.
57 |
58 | #### `renderItem`: (attrs: [ListItemAttrs](#listitemattrs--itemindex-number-rect-rect-visiblerect-rect-needsrender-boolean-item-componentitemprops-any-)) => ReactNode
59 |
60 | Returns the React element that corresponds to the specified item.
61 |
62 | ## Types
63 |
64 | #### `ItemProps` { key?: string, hash?: string, forceRender?: boolean }
65 |
66 | #### `ListItemAttrs` { itemIndex: number, rect: [Rect](types.md#rect--x-number-y-number-width-number-height-number-), visibleRect: [Rect](types.md#rect--x-number-y-number-width-number-height-number-), needsRender: boolean, Item: Component<[ItemProps](#itemprops--key-string-hash-string-forcerender-boolean-style-cssproperties-), any> };
67 |
--------------------------------------------------------------------------------
/packages/pannable/docs/gridcontent.md:
--------------------------------------------------------------------------------
1 | # \
2 |
3 | `GridContent` component displays data in grid layout. It provides the items that display the actual content.
4 |
5 | ## Usage
6 |
7 | ```js
8 | import React from 'react';
9 | import { Pad, GridContent } from 'react-pannable';
10 |
11 | class Page extends React.Component {
12 | render() {
13 | return (
14 |
15 | }
21 | />
22 |
23 | );
24 | }
25 | }
26 | ```
27 |
28 | [](https://codepen.io/cztflove/pen/EJJjYe)
29 |
30 | ## Props
31 |
32 | #### `width`?: number
33 |
34 | The width of the component. If not specified, it shrinks to fit the space available.
35 |
36 | #### `height`?: number
37 |
38 | The height of the component. If not specified, it shrinks to fit the space available.
39 |
40 | #### `direction`?: 'x' | 'y'
41 |
42 | The layout direction of the content. The default value is `y`.
43 |
44 | #### `rowSpacing`?: number
45 |
46 | The minimum spacing to use between rows.
47 |
48 | #### `columnSpacing`?: number
49 |
50 | The minimum spacing to use between columns.
51 |
52 | #### `itemCount`: number
53 |
54 | The number of items.
55 |
56 | #### `itemWidth`: number
57 |
58 | The width of items.
59 |
60 | #### `itemHeight`: number
61 |
62 | The height of items.
63 |
64 | #### `renderItem`: (attrs: [GridItemAttrs](#girditemattrs--itemindex-number-rowindex-number-columnindex-number-rect-rect-visiblerect-rect-needsrender-boolean-item-componentitemprops-any-)) => ReactNode
65 |
66 | Returns the React element that corresponds to the specified item.
67 |
68 | ## Types
69 |
70 | #### `ItemProps` { key?: string, forceRender?: boolean }
71 |
72 | #### GridItemAttrs { itemIndex: number, rowIndex: number, columnIndex: number, rect: [Rect](types.md#rect--x-number-y-number-width-number-height-number-), visibleRect: [Rect](types.md#rect--x-number-y-number-width-number-height-number-), needsRender: boolean, Item: Component<[ItemProps](#itemprops--key-string-forcerender-boolean-style-cssproperties-), any> };
73 |
--------------------------------------------------------------------------------
/packages/pannable/src/infinite/InfiniteInner.tsx:
--------------------------------------------------------------------------------
1 | import reducer, {
2 | initialInfiniteState,
3 | InfiniteState,
4 | InfiniteLayout,
5 | InfiniteMethods,
6 | } from './infiniteReducer';
7 | import { PadMethods, PadState } from '../pad/padReducer';
8 | import { XY } from '../interfaces';
9 | import { useIsomorphicLayoutEffect } from '../utils/hooks';
10 | import React, { useMemo, useReducer, useRef } from 'react';
11 |
12 | export interface InfiniteInnerProps {
13 | direction?: XY;
14 | pad: PadState;
15 | padMethods: PadMethods;
16 | layout: InfiniteLayout;
17 | render: (state: InfiniteState, methods: InfiniteMethods) => React.ReactNode;
18 | }
19 |
20 | export const InfiniteInner = React.memo((props) => {
21 | const { pad, padMethods, layout, render } = props;
22 | const [state, dispatch] = useReducer(reducer, initialInfiniteState);
23 | const prevStateRef = useRef(state);
24 | const layoutRef = useRef(layout);
25 | const delegateRef = useRef(padMethods);
26 | delegateRef.current = padMethods;
27 |
28 | const methodsRef = useRef({
29 | scrollTo(params) {
30 | dispatch({
31 | type: 'scrollTo',
32 | payload: { params, layout: layoutRef.current },
33 | });
34 | },
35 | });
36 |
37 | useMemo(() => {
38 | dispatch({ type: 'setState', payload: { pad } });
39 | }, [pad]);
40 |
41 | useIsomorphicLayoutEffect(() => {
42 | const prevState = prevStateRef.current;
43 | prevStateRef.current = state;
44 |
45 | if (state.scroll) {
46 | if (prevState.pad.contentSize !== state.pad.contentSize) {
47 | dispatch({
48 | type: 'scrollRecalculate',
49 | payload: { layout: layoutRef.current },
50 | });
51 | } else {
52 | if (
53 | !(state.scroll.animated === 1 || state.scroll.animated === true) ||
54 | (prevState.pad.deceleration && !state.pad.deceleration)
55 | ) {
56 | setTimeout(() => {
57 | dispatch({ type: 'scrollEnd' });
58 | }, 0);
59 | }
60 | }
61 | }
62 | }, [state]);
63 |
64 | useIsomorphicLayoutEffect(() => {
65 | if (state.scrollTo) {
66 | delegateRef.current.scrollTo(state.scrollTo);
67 | }
68 | }, [state.scrollTo]);
69 |
70 | return <>{render(state, methodsRef.current)}>;
71 | });
72 |
73 | export default InfiniteInner;
74 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # react-pannable
2 |
3 | Flexible and Customizable Layouts for Scrolling Content with [`React`](https://facebook.github.io/react/)
4 |
5 | [](https://www.npmjs.com/package/react-pannable)
6 | 
7 |
8 | ## Getting Started
9 |
10 | Install `react-pannable` using npm.
11 |
12 | ```shell
13 | npm install --save react-pannable
14 | ```
15 |
16 | ## API Reference
17 |
18 | #### [` `](/packages/pannable/docs/infinite.md) - Used to display a long list of data
19 |
20 | #### [` `](/packages/pannable/docs/carousel.md) - Used to play a number of looping items in sequence
21 |
22 | #### [` `](/packages/pannable/docs/pad.md) - Handles scrolling of content
23 |
24 | - [` `](/packages/pannable/docs/itemcontent.md) - Displays data with the size best fits the specified size.
25 | - [` `](/packages/pannable/docs/listcontent.md) - Displays data in a single column/row
26 | - [` `](/packages/pannable/docs/gridcontent.md) - Displays data in grid layout
27 |
28 | #### [` `](/packages/pannable/docs/pannable.md) - Can be panned(dragged) around with the touch/mouse
29 |
30 | ## Examples
31 |
32 | [All the examples!](https://n43.github.io/react-pannable/)
33 |
34 | Some `Carousel` demos
35 |
36 | - [Horizontal Carousel](https://n43.github.io/react-pannable/?path=/story/carousel--horizontal-carousel)
37 | - [Vertical Carousel](https://n43.github.io/react-pannable/?path=/story/carousel--vertical-carousel)
38 |
39 | Some `Infinite` demos
40 |
41 | - [Overview](https://n43.github.io/react-pannable/?path=/story/infinite--overview)
42 |
43 | Some `Pad` demos
44 |
45 | - [Overview](https://n43.github.io/react-pannable/?path=/story/pad--overview)
46 | - [Layout with Auto Resizing Content](https://n43.github.io/react-pannable/?path=/story/pad--layout-with-auto-resizing-content)
47 | - [Layout with Grid Content](https://n43.github.io/react-pannable/?path=/story/pad--layout-with-grid-content)
48 | - [Layout with List Content](https://n43.github.io/react-pannable/?path=/story/pad--layout-with-list-content)
49 | - [Layout with Multiple Nested Content](https://n43.github.io/react-pannable/?path=/story/pad--layout-with-multiple-nested-content)
50 |
51 | Some `Pannable` demos
52 |
53 | - [Draggable Notes](https://n43.github.io/react-pannable/?path=/story/pannable--draggable-notes)
54 | - [Adjustable Sticker](https://n43.github.io/react-pannable/?path=/story/pannable--adjustable-sticker)
55 |
56 | Some `AutoResizing` demos
57 |
58 | - [Overview](https://n43.github.io/react-pannable/?path=/story/autoresizing--overview)
59 |
60 | ## License
61 |
62 | [MIT License](/LICENSE)
63 |
--------------------------------------------------------------------------------
/packages/pannable/README.md:
--------------------------------------------------------------------------------
1 | # react-pannable
2 |
3 | Flexible and Customizable Layouts for Scrolling Content with [`React`](https://facebook.github.io/react/)
4 |
5 | [](https://www.npmjs.com/package/react-pannable)
6 | 
7 |
8 | ## Getting Started
9 |
10 | Install `react-pannable` using npm.
11 |
12 | ```shell
13 | npm install --save react-pannable
14 | ```
15 |
16 | ## API Reference
17 |
18 | #### [` `](/packages/pannable/docs/infinite.md) - Used to display a long list of data
19 |
20 | #### [` `](/packages/pannable/docs/carousel.md) - Used to play a number of looping items in sequence
21 |
22 | #### [` `](/packages/pannable/docs/pad.md) - Handles scrolling of content
23 |
24 | - [` `](/packages/pannable/docs/itemcontent.md) - Displays data with the size best fits the specified size
25 | - [` `](/packages/pannable/docs/listcontent.md) - Displays data in a single column/row
26 | - [` `](/packages/pannable/docs/gridcontent.md) - Displays data in grid layout
27 |
28 | #### [` `](/packages/pannable/docs/pannable.md) - Can be panned(dragged) around with the touch/mouse
29 |
30 | ## Examples
31 |
32 | [All the examples!](https://n43.github.io/react-pannable/)
33 |
34 | Some `Carousel` demos
35 |
36 | - [Horizontal Carousel](https://n43.github.io/react-pannable/?path=/story/carousel--horizontal-carousel)
37 | - [Vertical Carousel](https://n43.github.io/react-pannable/?path=/story/carousel--vertical-carousel)
38 |
39 | Some `Infinite` demos
40 |
41 | - [Overview](https://n43.github.io/react-pannable/?path=/story/infinite--overview)
42 |
43 | Some `Pad` demos
44 |
45 | - [Overview](https://n43.github.io/react-pannable/?path=/story/pad--overview)
46 | - [Layout with Auto Resizing Content](https://n43.github.io/react-pannable/?path=/story/pad--layout-with-auto-resizing-content)
47 | - [Layout with Grid Content](https://n43.github.io/react-pannable/?path=/story/pad--layout-with-grid-content)
48 | - [Layout with List Content](https://n43.github.io/react-pannable/?path=/story/pad--layout-with-list-content)
49 | - [Layout with Multiple Nested Content](https://n43.github.io/react-pannable/?path=/story/pad--layout-with-multiple-nested-content)
50 |
51 | Some `Pannable` demos
52 |
53 | - [Draggable Notes](https://n43.github.io/react-pannable/?path=/story/pannable--draggable-notes)
54 | - [Adjustable Sticker](https://n43.github.io/react-pannable/?path=/story/pannable--adjustable-sticker)
55 |
56 | Some `AutoResizing` demos
57 |
58 | - [Overview](https://n43.github.io/react-pannable/?path=/story/autoresizing--overview)
59 |
60 | ## License
61 |
62 | [MIT License](/LICENSE)
63 |
--------------------------------------------------------------------------------
/packages/pannable/docs/pannable.md:
--------------------------------------------------------------------------------
1 | # \
2 |
3 | `Pannable` component can be panned(dragged) around with the touch/mouse. You can implement the event handlers for this gesture recognizer with current translation and velocity.
4 |
5 | ## Usage
6 |
7 | ```js
8 | import React from 'react';
9 | import { Pannable } from 'react-pannable';
10 |
11 | class Page extends React.Component {
12 | state = {
13 | pos: { x: 0, y: 0 },
14 | startPos: null,
15 | };
16 |
17 | _onStart = () => {
18 | this.setState(({ pos }) => ({ startPos: pos }));
19 | };
20 | _onMove = ({ translation }) => {
21 | this.setState(({ startPos }) => ({
22 | pos: {
23 | x: startPos.x + translation.x,
24 | y: startPos.y + translation.y,
25 | },
26 | }));
27 | };
28 | _onEnd = () => {
29 | console.log('End', this.state.pos);
30 | };
31 | _onCancel = () => {
32 | this.setState(({ startPos }) => ({ pos: startPos }));
33 | };
34 |
35 | render() {
36 | const { pos } = this.state;
37 |
38 | return (
39 |
52 | );
53 | }
54 | }
55 | ```
56 |
57 | [](https://codepen.io/cztflove/pen/rbQpMQ)
58 |
59 | ## Props
60 |
61 | ... `div` props
62 |
63 | #### `enabled`?: boolean
64 |
65 | Determines whether the pan gesture recognizer is enabled. The default value is `true`. If set to `false` while the pan gesture recognizer is currently recognizing, it transitions to a cancelled state.
66 |
67 | #### `shouldStart`?: (evt: [PannableAttrs](#pannableattrs--translation-point-velocity-point-interval-number-target-htmlelement-)) => boolean
68 |
69 | Calls whether to recognize a pan.
70 |
71 | #### `onStart`?: (evt: [PannableAttrs](#pannableattrs--translation-point-velocity-point-interval-number-target-htmlelement-)) => void
72 |
73 | Calls when the touch/mouse has moved enough to be considered a pan.
74 |
75 | #### `onMove`?: (evt: [PannableAttrs](#pannableattrs--translation-point-velocity-point-interval-number-target-htmlelement-)) => void
76 |
77 | Calls when the touch/mouse moves.
78 |
79 | #### `onEnd`?: (evt: [PannableAttrs](#pannableattrs--translation-point-velocity-point-interval-number-target-htmlelement-)) => void
80 |
81 | Calls when the touch/mouse is left.
82 |
83 | #### `onCancel`?: (evt: [PannableAttrs](#pannableattrs--translation-point-velocity-point-interval-number-target-htmlelement-)) => void
84 |
85 | Calls when a system event cancels the recognizing pan.
86 |
87 | ## Types
88 |
89 | #### `PannableAttrs` { translation: [Point](types.md#point--x-number-y-number-), velocity: [Point](types.md#point--x-number-y-number-), interval: number, target: HTMLElement }
90 |
--------------------------------------------------------------------------------
/packages/demo/README.md:
--------------------------------------------------------------------------------
1 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
2 |
3 | ## Available Scripts
4 |
5 | In the project directory, you can run:
6 |
7 | ### `npm start`
8 |
9 | Runs the app in the development mode.
10 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
11 |
12 | The page will reload if you make edits.
13 | You will also see any lint errors in the console.
14 |
15 | ### `npm test`
16 |
17 | Launches the test runner in the interactive watch mode.
18 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
19 |
20 | ### `npm run build`
21 |
22 | Builds the app for production to the `build` folder.
23 | It correctly bundles React in production mode and optimizes the build for the best performance.
24 |
25 | The build is minified and the filenames include the hashes.
26 | Your app is ready to be deployed!
27 |
28 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
29 |
30 | ### `npm run eject`
31 |
32 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!**
33 |
34 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
35 |
36 | Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
37 |
38 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
39 |
40 | ## Learn More
41 |
42 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
43 |
44 | To learn React, check out the [React documentation](https://reactjs.org/).
45 |
46 | ### Code Splitting
47 |
48 | This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting
49 |
50 | ### Analyzing the Bundle Size
51 |
52 | This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size
53 |
54 | ### Making a Progressive Web App
55 |
56 | This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app
57 |
58 | ### Advanced Configuration
59 |
60 | This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration
61 |
62 | ### Deployment
63 |
64 | This section has moved here: https://facebook.github.io/create-react-app/docs/deployment
65 |
66 | ### `npm run build` fails to minify
67 |
68 | This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify
69 |
--------------------------------------------------------------------------------
/packages/demo/src/stories/pad/DemoText.js:
--------------------------------------------------------------------------------
1 | const DemoText = `Getting Started
2 | This page is an overview of the React documentation and related resources.
3 | React is a JavaScript library for building user interfaces. Learn what React is all about on our homepage or in the tutorial.
4 | Try React
5 | React has been designed from the start for gradual adoption, and you can use as little or as much React as you need. Whether you want to get a taste of React, add some interactivity to a simple HTML page, or start a complex React-powered app, the links in this section will help you get started.
6 | Online Playgrounds
7 | If you’re interested in playing around with React, you can use an online code playground. Try a Hello World template on CodePen, CodeSandbox, or Glitch.
8 | If you prefer to use your own text editor, you can also download this HTML file, edit it, and open it from the local filesystem in your browser. It does a slow runtime code transformation, so we’d only recommend using this for simple demos.
9 | Add React to a Website
10 | You can add React to an HTML page in one minute. You can then either gradually expand its presence, or keep it contained to a few dynamic widgets.
11 | Create a New React App
12 | When starting a React project, a simple HTML page with script tags might still be the best option. It only takes a minute to set up!
13 | As your application grows, you might want to consider a more integrated setup. There are several JavaScript toolchains we recommend for larger applications. Each of them can work with little to no configuration and lets you take full advantage of the rich React ecosystem.
14 | Learn React
15 | People come to React from different backgrounds and with different learning styles. Whether you prefer a more theoretical or a practical approach, we hope you’ll find this section helpful.
16 | If you prefer to learn by doing, start with our practical tutorial.
17 | If you prefer to learn concepts step by step, start with our guide to main concepts.
18 | Like any unfamiliar technology, React does have a learning curve. With practice and some patience, you will get the hang of it.
19 | First Examples
20 | The React homepage contains a few small React examples with a live editor. Even if you don’t know anything about React yet, try changing their code and see how it affects the result.
21 | React for Beginners
22 | If you feel that the React documentation goes at a faster pace than you’re comfortable with, check out this overview of React by Tania Rascia. It introduces the most important React concepts in a detailed, beginner-friendly way. Once you’re done, give the documentation another try!
23 | React for Designers
24 | If you’re coming from a design background, these resources are a great place to get started.
25 | JavaScript Resources
26 | The React documentation assumes some familiarity with programming in the JavaScript language. You don’t have to be an expert, but it’s harder to learn both React and JavaScript at the same time.
27 | We recommend going through this JavaScript overview to check your knowledge level. It will take you between 30 minutes and an hour but you will feel more confident learning React.
28 | `;
29 |
30 | export default DemoText;
31 |
--------------------------------------------------------------------------------
/packages/pannable/src/carousel/CarouselInner.tsx:
--------------------------------------------------------------------------------
1 | import reducer, {
2 | initialCarouselState,
3 | CarouselEvent,
4 | CarouselState,
5 | CarouselMethods,
6 | } from './carouselReducer';
7 | import { PadState, PadMethods } from '../pad/padReducer';
8 | import { XY } from '../interfaces';
9 | import { useIsomorphicLayoutEffect } from '../utils/hooks';
10 | import React, { useReducer, useRef } from 'react';
11 |
12 | export interface CarouselInnerProps {
13 | pad: PadState;
14 | padMethods: PadMethods;
15 | direction: XY;
16 | loop: boolean;
17 | autoplayEnabled: boolean;
18 | autoplayInterval: number;
19 | itemCount: number;
20 | onActiveIndexChange?: (evt: CarouselEvent) => void;
21 | render: (state: CarouselState, methods: CarouselMethods) => React.ReactNode;
22 | }
23 |
24 | export const CarouselInner = React.memo((props) => {
25 | const {
26 | pad,
27 | padMethods,
28 | direction,
29 | loop,
30 | autoplayEnabled,
31 | autoplayInterval,
32 | itemCount,
33 | onActiveIndexChange,
34 | render,
35 | } = props;
36 | const [state, dispatch] = useReducer(reducer, initialCarouselState);
37 | const prevStateRef = useRef(state);
38 | const delegate = { onActiveIndexChange, scrollTo: padMethods.scrollTo };
39 | const delegateRef = useRef(delegate);
40 | delegateRef.current = delegate;
41 |
42 | const methodsRef = useRef({
43 | scrollToIndex(params) {
44 | dispatch({ type: 'scrollToIndex', payload: params });
45 | },
46 | play(playing) {
47 | dispatch({ type: 'play', payload: playing });
48 | },
49 | });
50 |
51 | useIsomorphicLayoutEffect(() => {
52 | dispatch({ type: 'setState', payload: { pad } });
53 | }, [pad]);
54 |
55 | useIsomorphicLayoutEffect(() => {
56 | dispatch({ type: 'setState', payload: { direction, loop, itemCount } });
57 | }, [direction, loop, itemCount]);
58 |
59 | useIsomorphicLayoutEffect(() => {
60 | const prevState = prevStateRef.current;
61 | prevStateRef.current = state;
62 |
63 | if (prevState.activeIndex !== state.activeIndex) {
64 | const evt: CarouselEvent = {
65 | activeIndex: state.activeIndex,
66 | itemCount: state.itemCount,
67 | };
68 |
69 | if (delegateRef.current.onActiveIndexChange) {
70 | delegateRef.current.onActiveIndexChange(evt);
71 | }
72 | }
73 | }, [state]);
74 |
75 | useIsomorphicLayoutEffect(() => {
76 | if (state.scrollTo) {
77 | delegateRef.current.scrollTo(state.scrollTo);
78 | }
79 | }, [state.scrollTo]);
80 |
81 | useIsomorphicLayoutEffect(() => {
82 | if (!state.playing) {
83 | return;
84 | }
85 |
86 | const timer = setInterval(() => {
87 | dispatch({ type: 'next', payload: { animated: true } });
88 | }, autoplayInterval);
89 |
90 | return () => {
91 | clearInterval(timer);
92 | };
93 | }, [state.playing, autoplayInterval]);
94 |
95 | useIsomorphicLayoutEffect(() => {
96 | methodsRef.current.play(autoplayEnabled && !state.pad.drag);
97 | }, [autoplayEnabled, state.pad.drag]);
98 |
99 | return <>{render(state, methodsRef.current)}>;
100 | });
101 |
102 | export default CarouselInner;
103 |
--------------------------------------------------------------------------------
/packages/pannable/src/carousel/loopReducer.ts:
--------------------------------------------------------------------------------
1 | import { XY, WH, Point, Size, Action } from '../interfaces';
2 | import { initialPadState, PadState, PadScrollTo } from '../pad/padReducer';
3 | import { Reducer } from 'react';
4 |
5 | export type LoopState = {
6 | loopCount: number;
7 | loopOffset: number;
8 | loopWidth: number;
9 | direction: XY;
10 | pad: PadState;
11 | scrollTo: PadScrollTo | null;
12 | };
13 |
14 | export const initialLoopState: LoopState = {
15 | loopCount: 2,
16 | loopOffset: 0,
17 | loopWidth: 0,
18 | direction: 'x',
19 | pad: initialPadState,
20 | scrollTo: null,
21 | };
22 |
23 | const reducer: Reducer = (state, action) => {
24 | switch (action.type) {
25 | case 'setState':
26 | return validateReducer(setStateReducer(state, action), action);
27 | default:
28 | return state;
29 | }
30 | };
31 |
32 | export default reducer;
33 |
34 | const setStateReducer: Reducer>> = (
35 | state,
36 | action
37 | ) => {
38 | return {
39 | ...state,
40 | ...action.payload,
41 | };
42 | };
43 |
44 | const validateReducer: Reducer = (state) => {
45 | const { loopCount, loopWidth, loopOffset, direction } = state;
46 | const { size, contentSize, contentOffset } = state.pad;
47 |
48 | const width = direction === 'y' ? 'height' : 'width';
49 | let nextLoopWidth = contentSize[width] / loopCount;
50 | let nextLoopCount = 2;
51 |
52 | if (nextLoopWidth !== 0) {
53 | nextLoopCount += Math.floor(size[width] / nextLoopWidth);
54 | }
55 |
56 | if (loopWidth !== nextLoopWidth || loopCount !== nextLoopCount) {
57 | return {
58 | ...state,
59 | loopCount: nextLoopCount,
60 | loopWidth: nextLoopWidth,
61 | };
62 | }
63 |
64 | const [adjustedContentOffset, delta] = getAdjustedContentOffsetForLoop(
65 | contentOffset,
66 | size,
67 | loopWidth,
68 | loopCount,
69 | direction
70 | );
71 |
72 | if (contentOffset !== adjustedContentOffset) {
73 | return {
74 | ...state,
75 | loopOffset: loopOffset + delta,
76 | scrollTo: { offset: adjustedContentOffset, animated: false },
77 | };
78 | }
79 |
80 | return state;
81 | };
82 |
83 | function getAdjustedContentOffsetForLoop(
84 | offset: Point,
85 | size: Size,
86 | loopWidth: number,
87 | loopCount: number,
88 | direction: XY
89 | ): [Point, number] {
90 | if (loopCount === 1 || loopWidth === 0) {
91 | return [offset, 0];
92 | }
93 |
94 | const [width, x, y]: [WH, XY, XY] =
95 | direction === 'y' ? ['height', 'y', 'x'] : ['width', 'x', 'y'];
96 |
97 | const sizeWidth = size[width];
98 | const maxOffsetX = (sizeWidth - loopWidth * (loopCount - 1)) / 2;
99 | const minOffsetX = (sizeWidth - loopWidth * (loopCount + 1)) / 2;
100 | let offsetX = offset[x];
101 | let delta = 0;
102 |
103 | if (minOffsetX <= offsetX && offsetX <= maxOffsetX) {
104 | return [offset, 0];
105 | }
106 | if (offsetX < minOffsetX) {
107 | delta = Math.floor((maxOffsetX - offsetX) / loopWidth);
108 | } else if (maxOffsetX < offsetX) {
109 | delta = -Math.floor((offsetX - minOffsetX) / loopWidth);
110 | }
111 | offsetX += loopWidth * delta;
112 |
113 | return [{ [x]: offsetX, [y]: offset[y] } as Point, delta];
114 | }
115 |
--------------------------------------------------------------------------------
/packages/pannable/src/AutoResizing.tsx:
--------------------------------------------------------------------------------
1 | import { Size } from './interfaces';
2 | import { getResizeDetector } from './utils/resizeDetector';
3 | import { isEqualToSize } from './utils/geometry';
4 | import { useIsomorphicLayoutEffect } from './utils/hooks';
5 | import React, { useState, useRef, useMemo, useCallback } from 'react';
6 |
7 | export interface AutoResizingProps {
8 | width?: number;
9 | height?: number;
10 | onResize?: (size: Size) => void;
11 | render?: (size: Size) => React.ReactNode;
12 | }
13 |
14 | export const AutoResizing = React.memo(
15 | (props: React.ComponentProps<'div'> & AutoResizingProps) => {
16 | const { width, height, onResize, render, children, ...divProps } = props;
17 |
18 | const fixedSize = useMemo(() => {
19 | if (width !== undefined && height !== undefined) {
20 | return { width, height };
21 | }
22 | return null;
23 | }, [width, height]);
24 |
25 | const [size, setSize] = useState();
26 | const prevSizeRef = useRef(size);
27 | const resizeRef = useRef(null);
28 | const delegate = { onResize };
29 | const delegateRef = useRef(delegate);
30 | delegateRef.current = delegate;
31 |
32 | const calculateSize = useCallback(() => {
33 | const node = resizeRef.current;
34 |
35 | if (!node) {
36 | return;
37 | }
38 |
39 | const nextSize = {
40 | width: node.offsetWidth,
41 | height: node.offsetHeight,
42 | };
43 |
44 | setSize((size) => (isEqualToSize(size, nextSize) ? size : nextSize));
45 | }, []);
46 |
47 | useIsomorphicLayoutEffect(() => {
48 | const prevSize = prevSizeRef.current;
49 | prevSizeRef.current = size;
50 |
51 | if (size && !isEqualToSize(prevSize, size)) {
52 | if (delegateRef.current.onResize) {
53 | delegateRef.current.onResize(size);
54 | }
55 | }
56 | }, [size]);
57 |
58 | useIsomorphicLayoutEffect(() => {
59 | if (fixedSize) {
60 | setSize((size) => (isEqualToSize(size, fixedSize) ? size : fixedSize));
61 | return;
62 | }
63 |
64 | calculateSize();
65 |
66 | const detector = getResizeDetector();
67 | const node = resizeRef.current;
68 |
69 | if (!detector || !node) {
70 | return;
71 | }
72 |
73 | detector.listenTo(node, calculateSize);
74 |
75 | return () => {
76 | detector.uninstall(node);
77 | };
78 | }, [fixedSize, calculateSize]);
79 |
80 | const divStyle = useMemo(() => {
81 | const style: React.CSSProperties = {
82 | width: '100%',
83 | height: '100%',
84 | };
85 |
86 | if (divProps.style) {
87 | Object.assign(style, divProps.style);
88 | }
89 |
90 | if (width !== undefined) {
91 | style.width = width;
92 | }
93 | if (height !== undefined) {
94 | style.height = height;
95 | }
96 |
97 | return style;
98 | }, [width, height, divProps.style]);
99 |
100 | divProps.style = divStyle;
101 |
102 | let elem = children;
103 |
104 | if (size) {
105 | if (render) {
106 | elem = render(size);
107 | }
108 | } else {
109 | elem = null;
110 | }
111 |
112 | return (
113 |
114 | {elem}
115 |
116 | );
117 | }
118 | );
119 |
120 | export default AutoResizing;
121 |
--------------------------------------------------------------------------------
/packages/pannable/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-pannable",
3 | "version": "6.1.2",
4 | "description": "Flexible and Customizable Layouts for Scrolling Content with React",
5 | "keywords": [
6 | "react",
7 | "hooks",
8 | "scroll",
9 | "pan",
10 | "drag",
11 | "grid",
12 | "list",
13 | "carousel",
14 | "slide",
15 | "swiper",
16 | "page",
17 | "bounce",
18 | "table",
19 | "collection",
20 | "virtualized",
21 | "infinite-scroll"
22 | ],
23 | "license": "MIT",
24 | "homepage": "https://github.com/n43/react-pannable",
25 | "repository": {
26 | "type": "git",
27 | "url": "https://github.com/n43/react-pannable.git",
28 | "directory": "packages/pannable"
29 | },
30 | "bugs": {
31 | "url": "https://github.com/n43/react-pannable/issues"
32 | },
33 | "author": "Zhu DeMing ",
34 | "contributors": [
35 | "Chen SiHui <502672047@qq.com>"
36 | ],
37 | "main": "cjs/index.js",
38 | "module": "es/index.js",
39 | "unpkg": "dist/index.js",
40 | "types": "types/index.d.ts",
41 | "files": [
42 | "dist",
43 | "types",
44 | "cjs",
45 | "es",
46 | "lib",
47 | "src"
48 | ],
49 | "scripts": {
50 | "clean": "rimraf lib es cjs types dist coverage",
51 | "lint": "eslint --ext ts,tsx src",
52 | "coverage": "yarn lint",
53 | "tsc": "tsc",
54 | "build:es": "babel lib -d es",
55 | "build:cjs": "BABEL_ENV_MODULES=cjs babel lib -d cjs",
56 | "build:umd": "rollup -c",
57 | "build": "yarn lint && yarn tsc && yarn build:es && yarn build:cjs && yarn build:umd",
58 | "test": "jest -v",
59 | "prepare": "yarn clean && yarn build",
60 | "prepublishOnly": "cd ../demo && yarn deploy"
61 | },
62 | "peerDependencies": {
63 | "react": "^16.8.4 || ^17.0.0",
64 | "react-dom": "^16.8.4 || ^17.0.0"
65 | },
66 | "dependencies": {
67 | "@types/element-resize-detector": "^1.1.3",
68 | "element-resize-detector": "1.2.3"
69 | },
70 | "devDependencies": {
71 | "@babel/cli": "^7.14.8",
72 | "@babel/core": "^7.15.0",
73 | "@babel/plugin-proposal-class-properties": "^7.14.5",
74 | "@babel/plugin-proposal-nullish-coalescing-operator": "^7.14.5",
75 | "@babel/plugin-proposal-object-rest-spread": "^7.14.7",
76 | "@babel/plugin-proposal-optional-chaining": "^7.14.5",
77 | "@babel/plugin-transform-object-assign": "^7.14.5",
78 | "@babel/preset-env": "^7.15.0",
79 | "@rollup/plugin-commonjs": "^20.0.0",
80 | "@rollup/plugin-node-resolve": "^13.0.4",
81 | "@rollup/plugin-replace": "^3.0.0",
82 | "@types/node": "*",
83 | "@types/react": "*",
84 | "@typescript-eslint/eslint-plugin": "^4.29.2",
85 | "@typescript-eslint/parser": "^4.29.2",
86 | "babel-eslint": "^10.1.0",
87 | "babel-jest": "^27.0.6",
88 | "coveralls": "^3.1.1",
89 | "eslint": "^7.32.0",
90 | "eslint-config-react-app": "^6.0.0",
91 | "eslint-plugin-flowtype": "^5.9.0",
92 | "eslint-plugin-import": "^2.24.0",
93 | "eslint-plugin-jest": "^24.4.0",
94 | "eslint-plugin-jsx-a11y": "^6.4.1",
95 | "eslint-plugin-react": "^7.24.0",
96 | "eslint-plugin-react-hooks": "^4.2.0",
97 | "jest": "^27.0.6",
98 | "react": "^16.8.4",
99 | "react-dom": "^16.8.4",
100 | "react-test-renderer": "^16.8.4",
101 | "rimraf": "^3.0.2",
102 | "rollup": "^2.56.2",
103 | "rollup-plugin-terser": "^7.0.2",
104 | "typescript": "^4.3.5"
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/packages/pannable/src/pannableReducer.ts:
--------------------------------------------------------------------------------
1 | import { Action, Point, Time } from './interfaces';
2 | import { Reducer } from 'react';
3 |
4 | export type PannableState = {
5 | target: null | EventTarget;
6 | translation: null | Point;
7 | velocity: Point;
8 | interval: number;
9 | startPoint: Point;
10 | movePoint: Point;
11 | moveTime: Time;
12 | cancelled: boolean;
13 | };
14 |
15 | export const initialPannableState: PannableState = {
16 | target: null,
17 | translation: null,
18 | velocity: { x: 0, y: 0 },
19 | interval: 0,
20 | startPoint: { x: 0, y: 0 },
21 | movePoint: { x: 0, y: 0 },
22 | moveTime: 0,
23 | cancelled: true,
24 | };
25 |
26 | const reducer: Reducer = (state, action) => {
27 | switch (action.type) {
28 | case 'reset':
29 | return initialPannableState;
30 | case 'track':
31 | return trackReducer(state, action);
32 | case 'move':
33 | return moveReducer(state, action);
34 | case 'start':
35 | return startReducer(state, action);
36 | case 'end':
37 | return endReducer(state, action);
38 | default:
39 | return state;
40 | }
41 | };
42 |
43 | export default reducer;
44 |
45 | const trackReducer: Reducer<
46 | PannableState,
47 | Action<{ target: EventTarget; point: Point }>
48 | > = (state, action) => {
49 | const { target, point } = action.payload!;
50 | const moveTime = new Date().getTime();
51 |
52 | return {
53 | ...state,
54 | target,
55 | translation: null,
56 | velocity: { x: 0, y: 0 },
57 | interval: 0,
58 | startPoint: point,
59 | movePoint: point,
60 | moveTime,
61 | };
62 | };
63 |
64 | const moveReducer: Reducer = (state, action) => {
65 | const { point: nextMovePoint } = action.payload!;
66 | const { target, startPoint, movePoint, moveTime, translation } = state;
67 |
68 | if (!target) {
69 | return state;
70 | }
71 |
72 | const nextMoveTime = new Date().getTime();
73 | const nextInterval = nextMoveTime - moveTime;
74 | const nextVelocity = {
75 | x: (nextMovePoint.x - movePoint.x) / nextInterval,
76 | y: (nextMovePoint.y - movePoint.y) / nextInterval,
77 | };
78 |
79 | if (!translation) {
80 | return {
81 | ...state,
82 | velocity: nextVelocity,
83 | interval: nextInterval,
84 | movePoint: nextMovePoint,
85 | moveTime: nextMoveTime,
86 | };
87 | }
88 |
89 | const nextTranslation = {
90 | x: nextMovePoint.x - startPoint.x,
91 | y: nextMovePoint.y - startPoint.y,
92 | };
93 |
94 | /* on moving */
95 | return {
96 | ...state,
97 | translation: nextTranslation,
98 | velocity: nextVelocity,
99 | interval: nextInterval,
100 | movePoint: nextMovePoint,
101 | moveTime: nextMoveTime,
102 | };
103 | };
104 |
105 | const startReducer: Reducer = (state) => {
106 | const { target, translation, movePoint } = state;
107 |
108 | if (!target || translation) {
109 | return state;
110 | }
111 |
112 | return {
113 | ...state,
114 | translation: { x: 0, y: 0 },
115 | startPoint: movePoint,
116 | cancelled: true,
117 | };
118 | };
119 |
120 | const endReducer: Reducer = (state) => {
121 | const { target } = state;
122 |
123 | if (!target) {
124 | return state;
125 | }
126 |
127 | return {
128 | ...state,
129 | target: null,
130 | translation: null,
131 | cancelled: false,
132 | };
133 | };
134 |
--------------------------------------------------------------------------------
/packages/pannable/src/infinite/infiniteReducer.ts:
--------------------------------------------------------------------------------
1 | import { ListLayout } from '../pad/ListContent';
2 | import { initialPadState, PadScrollTo, PadState } from '../pad/padReducer';
3 | import { Action, Rect } from '../interfaces';
4 | import { Reducer } from 'react';
5 |
6 | export type InfiniteLayout = {
7 | box?: ListLayout;
8 | body?: ListLayout;
9 | };
10 |
11 | export type InfiniteScrollTo = PadScrollTo & {
12 | index?: number;
13 | reverseRect?: Rect;
14 | };
15 |
16 | export type InfiniteMethods = {
17 | scrollTo: (params: InfiniteScrollTo) => void;
18 | };
19 |
20 | export type InfiniteState = {
21 | pad: PadState;
22 | scroll: InfiniteScrollTo | null;
23 | scrollTo: PadScrollTo | null;
24 | };
25 |
26 | export const initialInfiniteState: InfiniteState = {
27 | pad: initialPadState,
28 | scroll: null,
29 | scrollTo: null,
30 | };
31 |
32 | const reducer: Reducer = (state, action) => {
33 | switch (action.type) {
34 | case 'setState':
35 | return setStateReducer(state, action);
36 | case 'scrollTo':
37 | return scrollToReducer(state, action);
38 | case 'scrollEnd':
39 | return scrollEndReducer(state, action);
40 | case 'scrollRecalculate':
41 | return scrollRecalculateReducer(state, action);
42 | default:
43 | return state;
44 | }
45 | };
46 |
47 | export default reducer;
48 |
49 | const setStateReducer: Reducer<
50 | InfiniteState,
51 | Action>
52 | > = (state, action) => {
53 | return {
54 | ...state,
55 | ...action.payload,
56 | };
57 | };
58 |
59 | const scrollToReducer: Reducer<
60 | InfiniteState,
61 | Action<{ params: InfiniteScrollTo; layout: InfiniteLayout }>
62 | > = (state, action) => {
63 | const { params, layout } = action.payload!;
64 | const nextScrollTo = { ...params };
65 | const { index, reverseRect } = params;
66 |
67 | if (index !== undefined) {
68 | nextScrollTo.rect = calculateRectForIndex(index, layout);
69 | } else if (reverseRect !== undefined) {
70 | nextScrollTo.rect = calculateRectForReverseRect(reverseRect, layout);
71 | }
72 |
73 | return {
74 | ...state,
75 | scrollTo: nextScrollTo,
76 | scroll: state.scroll || params,
77 | };
78 | };
79 |
80 | const scrollEndReducer: Reducer = (state, action) => {
81 | return {
82 | ...state,
83 | scroll: null,
84 | };
85 | };
86 |
87 | const scrollRecalculateReducer: Reducer<
88 | InfiniteState,
89 | Action<{ layout: InfiniteLayout }>
90 | > = (state, action) => {
91 | const { layout } = action.payload!;
92 | const { scroll } = state;
93 |
94 | if (!scroll) {
95 | return state;
96 | }
97 |
98 | return scrollToReducer(state, {
99 | type: 'scrollTo',
100 | payload: { params: scroll, layout },
101 | });
102 | };
103 |
104 | function calculateRectForIndex(index: number, layout: InfiniteLayout): Rect {
105 | const { box, body } = layout;
106 | let rect: Rect = { x: 0, y: 0, width: 0, height: 0 };
107 |
108 | if (box && box.layoutList[1]) {
109 | rect = box.layoutList[1].rect;
110 | }
111 | if (body) {
112 | index = Math.min(index, body.layoutList.length - 1);
113 |
114 | if (index >= 0) {
115 | const attrs = body.layoutList[index];
116 |
117 | rect = {
118 | x: rect.x + attrs.rect.x,
119 | y: rect.y + attrs.rect.y,
120 | width: attrs.rect.width,
121 | height: attrs.rect.height,
122 | };
123 | }
124 | }
125 |
126 | return rect;
127 | }
128 |
129 | function calculateRectForReverseRect(
130 | rrect: Rect,
131 | layout: InfiniteLayout
132 | ): Rect {
133 | const { box } = layout;
134 | let rect: Rect = { x: 0, y: 0, width: rrect.width, height: rrect.height };
135 |
136 | if (box) {
137 | rect.x = box.size.width - rect.width - rrect.x;
138 | rect.y = box.size.height - rect.height - rrect.y;
139 | }
140 |
141 | return rect;
142 | }
143 |
--------------------------------------------------------------------------------
/packages/pannable/src/carousel/Carousel.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | CarouselScrollTo,
3 | CarouselState,
4 | CarouselEvent,
5 | CarouselMethods,
6 | } from './carouselReducer';
7 | import CarouselInner from './CarouselInner';
8 | import GridContent, { GridLayoutAttrs } from '../pad/GridContent';
9 | import Loop from './Loop';
10 | import Pad from '../pad/Pad';
11 | import { XY } from '../interfaces';
12 | import { useIsomorphicLayoutEffect } from '../utils/hooks';
13 | import React, { useRef, useCallback } from 'react';
14 |
15 | export interface CarouselProps {
16 | itemCount: number;
17 | renderItem: (attrs: GridLayoutAttrs) => React.ReactNode;
18 | direction?: XY;
19 | loop?: boolean;
20 | autoplayEnabled?: boolean;
21 | autoplayInterval?: number;
22 | onActiveIndexChange?: (evt: CarouselEvent) => void;
23 | scrollToIndex?: CarouselScrollTo | null;
24 | render?: (state: CarouselState, methods: CarouselMethods) => React.ReactNode;
25 | }
26 |
27 | export const Carousel = React.memo<
28 | Omit, 'render'> & CarouselProps
29 | >((props) => {
30 | const {
31 | itemCount,
32 | renderItem,
33 | direction = 'x',
34 | loop = true,
35 | autoplayEnabled = true,
36 | autoplayInterval = 5000,
37 | onActiveIndexChange,
38 | scrollToIndex,
39 | render,
40 | children,
41 | ...padProps
42 | } = props;
43 | const {
44 | width,
45 | height,
46 | pagingEnabled = true,
47 | directionalLockEnabled = true,
48 | renderOverlay,
49 | onMouseEnter,
50 | onMouseLeave,
51 | } = padProps;
52 | const methodsRef = useRef();
53 | const delegate = { onMouseEnter, onMouseLeave };
54 | const delegateRef = useRef(delegate);
55 | delegateRef.current = delegate;
56 |
57 | const padOnMouseEnter = useCallback((evt) => {
58 | const methods = methodsRef.current;
59 |
60 | if (methods) {
61 | methods.play(false);
62 | }
63 |
64 | if (delegateRef.current.onMouseEnter) {
65 | delegateRef.current.onMouseEnter(evt);
66 | }
67 | }, []);
68 | const padOnMouseLeave = useCallback((evt) => {
69 | const methods = methodsRef.current;
70 |
71 | if (methods) {
72 | methods.play(true);
73 | }
74 |
75 | if (delegateRef.current.onMouseLeave) {
76 | delegateRef.current.onMouseLeave(evt);
77 | }
78 | }, []);
79 |
80 | useIsomorphicLayoutEffect(() => {
81 | if (scrollToIndex) {
82 | const methods = methodsRef.current;
83 |
84 | if (methods) {
85 | methods.scrollToIndex(scrollToIndex);
86 | }
87 | }
88 | }, [scrollToIndex]);
89 |
90 | padProps.pagingEnabled = pagingEnabled;
91 | padProps.directionalLockEnabled = directionalLockEnabled;
92 |
93 | if (autoplayEnabled) {
94 | padProps.onMouseEnter = padOnMouseEnter;
95 | padProps.onMouseLeave = padOnMouseLeave;
96 | }
97 |
98 | if (direction === 'x') {
99 | padProps.boundY = padProps.boundY ?? 0;
100 | } else {
101 | padProps.boundX = padProps.boundX ?? 0;
102 | }
103 |
104 | padProps.renderOverlay = (pad, methods) => (
105 | <>
106 | {
116 | methodsRef.current = methods;
117 |
118 | return render ? render(state, methods) : children;
119 | }}
120 | />
121 | {renderOverlay ? renderOverlay(pad, methods) : null}
122 | >
123 | );
124 |
125 | const content = (
126 |
135 | );
136 |
137 | if (loop) {
138 | return (
139 |
140 | {content}
141 |
142 | );
143 | }
144 |
145 | return {content} ;
146 | });
147 |
148 | export default Carousel;
149 |
--------------------------------------------------------------------------------
/packages/pannable/src/infinite/Infinite.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | InfiniteScrollTo,
3 | InfiniteState,
4 | InfiniteLayout,
5 | InfiniteMethods,
6 | } from './infiniteReducer';
7 | import InfiniteInner from './InfiniteInner';
8 | import ListContent, { ListLayoutAttrs } from '../pad/ListContent';
9 | import Pad from '../pad/Pad';
10 | import { XY } from '../interfaces';
11 | import { useIsomorphicLayoutEffect } from '../utils/hooks';
12 | import React, { useRef } from 'react';
13 |
14 | export interface InfiniteProps {
15 | itemCount: number;
16 | renderItem: (attrs: ListLayoutAttrs) => React.ReactNode;
17 | direction?: XY;
18 | spacing?: number;
19 | estimatedItemWidth?: number | ((itemIndex: number) => number);
20 | estimatedItemHeight?: number | ((itemIndex: number) => number);
21 | renderHeader?: (attrs: ListLayoutAttrs) => React.ReactNode;
22 | renderFooter?: (attrs: ListLayoutAttrs) => React.ReactNode;
23 | scrollTo?: InfiniteScrollTo | null;
24 | render?: (state: InfiniteState, methods: InfiniteMethods) => React.ReactNode;
25 | infiniteStyle?: React.CSSProperties;
26 | bodyStyle?: React.CSSProperties;
27 | }
28 |
29 | export const Infinite = React.memo<
30 | Omit, 'render' | 'scrollTo'> & InfiniteProps
31 | >((props) => {
32 | const {
33 | itemCount,
34 | renderItem,
35 | direction = 'y',
36 | spacing = 0,
37 | estimatedItemWidth = 0,
38 | estimatedItemHeight = 0,
39 | renderHeader,
40 | renderFooter,
41 | scrollTo,
42 | render,
43 | infiniteStyle,
44 | bodyStyle,
45 | children,
46 | ...padProps
47 | } = props;
48 | const {
49 | width,
50 | height,
51 | renderOverlay,
52 | directionalLockEnabled = true,
53 | } = padProps;
54 | const layoutRef = useRef({});
55 | const methodsRef = useRef();
56 |
57 | useIsomorphicLayoutEffect(() => {
58 | if (scrollTo) {
59 | const methods = methodsRef.current;
60 |
61 | if (methods) {
62 | methods.scrollTo(scrollTo);
63 | }
64 | }
65 | }, [scrollTo]);
66 |
67 | padProps.directionalLockEnabled = directionalLockEnabled;
68 |
69 | if (direction === 'x') {
70 | padProps.boundY = padProps.boundY ?? 0;
71 | } else {
72 | padProps.boundX = padProps.boundX ?? 0;
73 | }
74 |
75 | padProps.renderOverlay = (pad, padMethods) => (
76 | <>
77 | {
83 | methodsRef.current = methods;
84 |
85 | return render ? render(state, methods) : children;
86 | }}
87 | />
88 | {renderOverlay ? renderOverlay(pad, padMethods) : null}
89 | >
90 | );
91 |
92 | return (
93 |
94 | {
101 | const { itemIndex, Item } = attrs;
102 |
103 | if (itemIndex === 0) {
104 | return renderHeader ? renderHeader(attrs) : null;
105 | }
106 | if (itemIndex === 2) {
107 | return renderFooter ? renderFooter(attrs) : null;
108 | }
109 |
110 | return (
111 | -
112 |
{
123 | layoutRef.current.body = layout;
124 | return null;
125 | }}
126 | />
127 |
128 | );
129 | }}
130 | render={(layout) => {
131 | layoutRef.current.box = layout;
132 | return null;
133 | }}
134 | />
135 |
136 | );
137 | });
138 |
139 | export default Infinite;
140 |
--------------------------------------------------------------------------------
/packages/pannable/docs/pad.md:
--------------------------------------------------------------------------------
1 | # \
2 |
3 | `Pad` component handles scrolling of content. its origin is adjustable over the content. it tracks the movements of the touch/mouse and adjusts the origin accordingly. by default, it bounces back when scrolling exceeds the bounds of the content.
4 |
5 | `Pad` component must know the size of the content so it knows when to stop scrolling. You should specify the content by defining one of the following components. These components can be nested to achieve some complex layouts.
6 |
7 | - [` `](itemcontent.md) - Displays data with the size best fits the specified size
8 | - [` `](listcontent.md) - Displays data in a single column/row
9 | - [` `](gridcontent.md) - Displays data in grid layout
10 |
11 | ## Usage
12 |
13 | ```js
14 | import React from 'react';
15 | import { Pad, ItemContent } from 'react-pannable';
16 |
17 | class Page extends React.Component {
18 | render() {
19 | return (
20 |
21 |
22 |
23 |
24 |
25 | );
26 | }
27 | }
28 | ```
29 |
30 | [](https://codepen.io/cztflove/pen/KYrRgQ)
31 |
32 | ## Props
33 |
34 | ... [`Pannable`](pannable.md#props) props
35 |
36 | #### `width`: number
37 |
38 | the width of the component.
39 |
40 | #### `height`: number
41 |
42 | the height of the component.
43 |
44 | #### `pagingEnabled`?: boolean
45 |
46 | Determines whether paging is enabled for the component.
47 |
48 | #### `directionalLockEnabled`?: boolean
49 |
50 | Determines whether scrolling is disabled in a particular direction.
51 |
52 | #### `boundX`?: boolean
53 |
54 | Determines whether bouncing always occurs when horizontal scrolling reaches the end of the content. The default value is `1`.
55 |
56 | #### `boundY`?: boolean
57 |
58 | Determines whether bouncing always occurs when vertical scrolling reaches the end of the content. The default value is `1`.
59 |
60 | #### `onScroll`?: (evt: [PadAttrs](#padattrs--contentoffset-point-contentvelocity-point-size-size-contentsize-size-dragging-boolean-decelerating-boolean-)) => void
61 |
62 | Calls when scrolls the content.
63 |
64 | #### `onStartDragging`?: (evt: [PadAttrs](#padattrs--contentoffset-point-contentvelocity-point-size-size-contentsize-size-dragging-boolean-decelerating-boolean-)) => void
65 |
66 | Calls when dragging started.
67 |
68 | #### `onDragEnd`?: (evt: [PadAttrs](#padattrs--contentoffset-point-contentvelocity-point-size-size-contentsize-size-dragging-boolean-decelerating-boolean-)) => void
69 |
70 | Calls when dragging ended.
71 |
72 | #### `onStartDecelerating`?: (evt: [PadAttrs](#padattrs--contentoffset-point-contentvelocity-point-size-size-contentsize-size-dragging-boolean-decelerating-boolean-)) => void
73 |
74 | Calls when decelerating started.
75 |
76 | #### `onEndDecelerating`?: (evt: [PadAttrs](#padattrs--contentoffset-point-contentvelocity-point-size-size-contentsize-size-dragging-boolean-decelerating-boolean-)) => void
77 |
78 | Calls when decelerating ended.
79 |
80 | #### `onResizeContent`?: (evt: [PadAttrs](#padattrs--contentoffset-point-contentvelocity-point-size-size-contentsize-size-dragging-boolean-decelerating-boolean-)) => void
81 |
82 | Calls when changes the size of the content.
83 |
84 | #### `renderBackground`: (attrs: [PadAttrs](#padattrs--contentoffset-point-contentvelocity-point-size-size-contentsize-size-dragging-boolean-decelerating-boolean-)) => ReactNode
85 |
86 | Returns the React element that corresponds to the background.
87 |
88 | #### `renderOverlay`: (attrs: [PadAttrs](#padattrs--contentoffset-point-contentvelocity-point-size-size-contentsize-size-dragging-boolean-decelerating-boolean-)) => ReactNode
89 |
90 | Returns the React element that corresponds to the overlay.
91 |
92 | #### `scrollTo`?: { point?: [Point](types.md#point--x-number-y-number-), offset?: [Point](types.md#point--x-number-y-number-), rect?: [Rect](types.md#rect--x-number-y-number-width-number-height-number-), align?: [Align2D](types.md#align2d--x-align-y-align---align), animated?: boolean }
93 |
94 | Scrolls the content to the specified offset.
95 |
96 | ## Types
97 |
98 | #### `PadAttrs` { contentOffset: [Point](types.md#point--x-number-y-number-), contentVelocity: [Point](types.md#point--x-number-y-number-), size: [Size](types.md#size--width-number-height-number-), contentSize: [Size](types.md#size--width-number-height-number-), dragging: boolean, decelerating: boolean }
99 |
--------------------------------------------------------------------------------
/packages/pannable/src/pad/ItemContent.tsx:
--------------------------------------------------------------------------------
1 | import PadContext from './PadContext';
2 | import { Size } from '../interfaces';
3 | import { useIsomorphicLayoutEffect } from '../utils/hooks';
4 | import { isEqualToSize } from '../utils/geometry';
5 | import { getResizeDetector } from '../utils/resizeDetector';
6 | import React, {
7 | useState,
8 | useMemo,
9 | useRef,
10 | useContext,
11 | useCallback,
12 | } from 'react';
13 |
14 | function contentOnResize() {}
15 |
16 | const wrapperStyle: React.CSSProperties = {
17 | position: 'absolute',
18 | top: 0,
19 | left: 0,
20 | };
21 |
22 | export interface ItemContentProps {
23 | width?: number;
24 | height?: number;
25 | autoResizing?: boolean;
26 | render?: () => React.ReactNode;
27 | }
28 |
29 | export const ItemContent = React.memo<
30 | React.ComponentProps<'div'> & ItemContentProps
31 | >((props) => {
32 | const {
33 | width,
34 | height,
35 | autoResizing = false,
36 | render,
37 | children,
38 | ...divProps
39 | } = props;
40 | const context = useContext(PadContext);
41 |
42 | const fixedWidth = width ?? context.width;
43 | const fixedHeight = height ?? context.height;
44 | const fixedSize = useMemo(() => {
45 | if (fixedWidth !== undefined && fixedHeight !== undefined) {
46 | return { width: fixedWidth, height: fixedHeight };
47 | }
48 | return null;
49 | }, [fixedWidth, fixedHeight]);
50 |
51 | const [size, setSize] = useState(null);
52 | const prevSizeRef = useRef(size);
53 | const resizeRef = useRef(null);
54 | const delegate = { onResize: context.onResize };
55 | const delegateRef = useRef(delegate);
56 | delegateRef.current = delegate;
57 |
58 | const calculateSize = useCallback(() => {
59 | const node = resizeRef.current;
60 |
61 | if (!node) {
62 | return;
63 | }
64 |
65 | const nextSize: Size = {
66 | width: node.offsetWidth,
67 | height: node.offsetHeight,
68 | };
69 |
70 | setSize((size) => (isEqualToSize(size, nextSize) ? size : nextSize));
71 | }, []);
72 |
73 | useIsomorphicLayoutEffect(() => {
74 | const prevSize = prevSizeRef.current;
75 | prevSizeRef.current = size;
76 |
77 | if (size && !isEqualToSize(prevSize, size)) {
78 | delegateRef.current.onResize(size);
79 | }
80 | }, [size]);
81 |
82 | useIsomorphicLayoutEffect(() => {
83 | if (fixedSize) {
84 | setSize((size) => (isEqualToSize(size, fixedSize) ? size : fixedSize));
85 | return;
86 | }
87 |
88 | calculateSize();
89 |
90 | if (!autoResizing) {
91 | return;
92 | }
93 |
94 | const detector = getResizeDetector();
95 | const node = resizeRef.current;
96 |
97 | if (!detector || !node) {
98 | return;
99 | }
100 |
101 | detector.listenTo(node, calculateSize);
102 |
103 | return () => {
104 | detector.uninstall(node);
105 | };
106 | }, [fixedSize, autoResizing, calculateSize]);
107 |
108 | const resizeStyle = useMemo(() => {
109 | const style: React.CSSProperties = { position: 'absolute' };
110 |
111 | if (fixedWidth !== undefined) {
112 | style.width = fixedWidth;
113 | }
114 | if (fixedHeight !== undefined) {
115 | style.height = fixedHeight;
116 | }
117 |
118 | return style;
119 | }, [fixedWidth, fixedHeight]);
120 |
121 | let elem = children;
122 |
123 | if (render) {
124 | elem = render();
125 | }
126 |
127 | if (!fixedSize) {
128 | elem = (
129 |
130 |
131 | {elem}
132 |
133 |
134 | );
135 | }
136 |
137 | const divStyle = useMemo(() => {
138 | const style: React.CSSProperties = { position: 'relative' };
139 |
140 | if (size) {
141 | style.width = size.width;
142 | style.height = size.height;
143 | }
144 | if (divProps.style) {
145 | Object.assign(style, divProps.style);
146 | }
147 |
148 | return style;
149 | }, [size, divProps.style]);
150 |
151 | divProps.style = divStyle;
152 |
153 | return (
154 |
155 |
163 | {elem}
164 |
165 |
166 | );
167 | });
168 |
169 | export default ItemContent;
170 |
--------------------------------------------------------------------------------
/packages/pannable/src/carousel/carouselReducer.ts:
--------------------------------------------------------------------------------
1 | import { XY, WH, Point, Size, Action } from '../interfaces';
2 | import { initialPadState, PadState, PadScrollTo } from '../pad/padReducer';
3 | import { Reducer } from 'react';
4 |
5 | export type CarouselEvent = {
6 | activeIndex: number;
7 | itemCount: number;
8 | };
9 |
10 | export type CarouselScrollTo = {
11 | index: number | ((evt: CarouselEvent) => number);
12 | animated: boolean;
13 | };
14 |
15 | export type CarouselMethods = {
16 | scrollToIndex: (params: CarouselScrollTo) => void;
17 | play: (playing: boolean) => void;
18 | };
19 |
20 | export type CarouselState = {
21 | pad: PadState;
22 | activeIndex: number;
23 | direction: XY;
24 | loop: boolean;
25 | itemCount: number;
26 | scrollTo: PadScrollTo | null;
27 | playing: boolean;
28 | };
29 |
30 | export const initialCarouselState: CarouselState = {
31 | pad: initialPadState,
32 | activeIndex: 0,
33 | direction: 'x',
34 | loop: true,
35 | itemCount: 0,
36 | scrollTo: null,
37 | playing: false,
38 | };
39 |
40 | const reducer: Reducer = (state, action) => {
41 | switch (action.type) {
42 | case 'setState':
43 | return validateReducer(setStateReducer(state, action), action);
44 | case 'scrollToIndex':
45 | return scrollToIndexReducer(state, action);
46 | case 'next':
47 | return nextReducer(state, action);
48 | case 'play':
49 | return playReducer(state, action);
50 | default:
51 | return state;
52 | }
53 | };
54 |
55 | export default reducer;
56 |
57 | const setStateReducer: Reducer<
58 | CarouselState,
59 | Action>
60 | > = (state, action) => {
61 | return {
62 | ...state,
63 | ...action.payload,
64 | };
65 | };
66 |
67 | const validateReducer: Reducer = (state, action) => {
68 | const { direction, itemCount, activeIndex } = state;
69 | const { contentOffset, size } = state.pad;
70 | const nextActiveIndex = calculateActiveIndex(
71 | contentOffset,
72 | size,
73 | itemCount,
74 | direction
75 | );
76 |
77 | if (activeIndex === nextActiveIndex) {
78 | return state;
79 | }
80 |
81 | return {
82 | ...state,
83 | activeIndex: nextActiveIndex,
84 | };
85 | };
86 |
87 | const scrollToIndexReducer: Reducer> = (
88 | state,
89 | action
90 | ) => {
91 | const { activeIndex, itemCount, direction, loop } = state;
92 | const { contentOffset, size } = state.pad;
93 | const { animated } = action.payload!;
94 | let { index } = action.payload!;
95 |
96 | if (typeof index === 'function') {
97 | index = index({ activeIndex, itemCount });
98 | }
99 |
100 | if (!loop) {
101 | index = Math.max(0, Math.min(index, itemCount - 1));
102 | }
103 |
104 | const offset = getContentOffsetForIndexOffset(
105 | index - activeIndex,
106 | contentOffset,
107 | size,
108 | direction
109 | );
110 |
111 | return { ...state, scrollTo: { offset, animated } };
112 | };
113 |
114 | const nextReducer: Reducer = (state, action) => {
115 | const { activeIndex, itemCount, loop } = state;
116 | const { animated } = action.payload;
117 | let nextActiveIndex = activeIndex + 1;
118 |
119 | if (!loop) {
120 | nextActiveIndex = nextActiveIndex % itemCount;
121 | }
122 |
123 | return scrollToIndexReducer(state, {
124 | type: 'scrollToIndex',
125 | payload: { index: nextActiveIndex, animated },
126 | });
127 | };
128 |
129 | const playReducer: Reducer> = (
130 | state,
131 | action
132 | ) => {
133 | return {
134 | ...state,
135 | playing: action.payload!,
136 | };
137 | };
138 |
139 | function calculateActiveIndex(
140 | offset: Point,
141 | size: Size,
142 | itemCount: number,
143 | direction: XY
144 | ): number {
145 | const [width, x]: [WH, XY] =
146 | direction === 'y' ? ['height', 'y'] : ['width', 'x'];
147 | const sizeWidth = size[width];
148 | let index = 0;
149 |
150 | if (sizeWidth > 0) {
151 | index = Math.round(-offset[x] / sizeWidth);
152 | }
153 |
154 | return index % itemCount;
155 | }
156 |
157 | function getContentOffsetForIndexOffset(
158 | indexOffset: number,
159 | offset: Point,
160 | size: Size,
161 | direction: XY
162 | ): Point {
163 | const [width, x, y]: [WH, XY, XY] =
164 | direction === 'y' ? ['height', 'y', 'x'] : ['width', 'x', 'y'];
165 | const sizeWidth = size[width];
166 |
167 | return { [x]: offset[x] - indexOffset * sizeWidth, [y]: offset[y] } as Point;
168 | }
169 |
--------------------------------------------------------------------------------
/packages/demo/src/stories/infinite/index.stories.js:
--------------------------------------------------------------------------------
1 | import React, { useCallback, useMemo } from 'react';
2 | import {
3 | withKnobs,
4 | object,
5 | number,
6 | select,
7 | boolean,
8 | } from '@storybook/addon-knobs';
9 | import { AutoResizing, Infinite, ItemContent } from 'react-pannable';
10 | import InfoCard from './Infocard';
11 | import Banner from './Banner';
12 | import '../../ui/overview.css';
13 | import './infinite.css';
14 |
15 | export default {
16 | title: 'Infinite',
17 | decorators: [withKnobs],
18 | };
19 |
20 | const data = [
21 | { id: 'p001', title: 'Item 0', linesOfDesc: 9 },
22 | { id: 'p002', title: 'Item 1', linesOfDesc: 13 },
23 | { id: 'p003', title: 'Item 2', linesOfDesc: 7 },
24 | { id: 'p004', title: 'Item 3', linesOfDesc: 7 },
25 | { id: 'p005', title: 'Item 4', linesOfDesc: 11 },
26 | { id: 'p006', title: 'Item 5', linesOfDesc: 8 },
27 | { id: 'p007', title: 'Item 6', linesOfDesc: 10 },
28 | { id: 'p008', title: 'Item 7', linesOfDesc: 7 },
29 | { id: 'p009', title: 'Item 8', linesOfDesc: 15 },
30 | { id: 'p010', title: 'Item 9', linesOfDesc: 11 },
31 | { id: 'p011', title: 'Item 10', linesOfDesc: 12 },
32 | { id: 'p012', title: 'Item 11', linesOfDesc: 6 },
33 | { id: 'p013', title: 'Item 12', linesOfDesc: 8 },
34 | { id: 'p014', title: 'Item 13', linesOfDesc: 14 },
35 | { id: 'p015', title: 'Item 14', linesOfDesc: 14 },
36 | { id: 'p016', title: 'Item 15', linesOfDesc: 6 },
37 | { id: 'p017', title: 'Item 16', linesOfDesc: 7 },
38 | { id: 'p018', title: 'Item 17', linesOfDesc: 9 },
39 | { id: 'p019', title: 'Item 18', linesOfDesc: 10 },
40 | { id: 'p020', title: 'Item 19', linesOfDesc: 13 },
41 | ];
42 |
43 | export const Overview = () => {
44 | const spacing = number('spacing', 16, {}, 'props');
45 | const list = object('Data List', data);
46 | const header = select(
47 | 'renderHeader',
48 | { 'No Header': undefined, 'Hello World': 'Hello World' },
49 | undefined,
50 | 'props'
51 | );
52 | const footer = select(
53 | 'renderFooter',
54 | { 'No Footer': undefined, 'loading...': 'loading...' },
55 | undefined,
56 | 'props'
57 | );
58 |
59 | const scrollType = select(
60 | 'Scroll Action',
61 | { null: '', scrollToIndex: 'scrollToIndex' },
62 | '',
63 | 'Scrolling'
64 | );
65 | let index;
66 | let align;
67 | let animated;
68 |
69 | if (scrollType === 'scrollToIndex') {
70 | index = number('index', 0, {}, 'Scrolling');
71 | align = select(
72 | 'align',
73 | { auto: 'auto', center: 'center', end: 'end', start: 'start' },
74 | 'start',
75 | 'Scrolling'
76 | );
77 | animated = boolean('animated', true, 'Scrolling');
78 | }
79 |
80 | const scrollToIndex = useMemo(() => {
81 | if (scrollType !== 'scrollToIndex') {
82 | return null;
83 | }
84 |
85 | return { index, align, animated };
86 | }, [scrollType, index, align, animated]);
87 |
88 | return (
89 |
90 |
Infinite
91 |
92 | Infinite component used to display a long list of data.
93 |
94 |
95 |
(
97 | {
105 | const info = list[itemIndex];
106 |
107 | return (
108 | -
109 |
110 |
111 |
112 |
113 | );
114 | }}
115 | renderHeader={({ Item }) => {
116 | if (!header) {
117 | return null;
118 | }
119 | return (
120 | -
121 |
122 | {header}
123 |
124 |
125 | );
126 | }}
127 | renderFooter={({ Item }) => {
128 | if (!footer) {
129 | return null;
130 | }
131 | return (
132 | -
133 |
134 | {footer}
135 |
136 |
137 | );
138 | }}
139 | scrollToIndex={scrollToIndex}
140 | />
141 | )}
142 | />
143 |
144 |
145 | );
146 | };
147 |
--------------------------------------------------------------------------------
/packages/website/src/pages/DraggableNotes.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useCallback, useMemo, useRef } from 'react';
2 | import { AutoResizing, Pannable } from 'react-pannable';
3 | import clsx from 'clsx';
4 | import './pan.css';
5 | import SvgNote from './SvgNote';
6 |
7 | const DraggableNotes = () => {
8 | const panEnabled = true;
9 | const cancelsOut = false;
10 |
11 | const [enabled, setEnabled] = useState(true);
12 | const [drag, setDrag] = useState(null);
13 | const [boxSize, setBoxSize] = useState({ width: 400, height: 600 });
14 | const points = {
15 | note0: useState({ x: 20, y: 20 }),
16 | note1: useState({ x: 20, y: 240 }),
17 | };
18 | const pointsRef = useRef();
19 | pointsRef.current = points;
20 |
21 | const onBoxResize = useCallback(size => {
22 | setBoxSize(size);
23 | }, []);
24 |
25 | const shouldStart = useCallback(
26 | ({ target }) => !!getDraggableKey(target),
27 | []
28 | );
29 |
30 | const onStart = useCallback(evt => {
31 | const key = getDraggableKey(evt.target);
32 | const startPoint = pointsRef.current[key][0];
33 |
34 | setDrag({ key, startPoint });
35 | console.log('onStart', evt);
36 | }, []);
37 |
38 | const onMove = useCallback(
39 | evt => {
40 | if (!drag) {
41 | return;
42 | }
43 |
44 | const setPoint = points[drag.key][1];
45 |
46 | setPoint({
47 | x: drag.startPoint.x + evt.translation.x,
48 | y: drag.startPoint.y + evt.translation.y,
49 | });
50 | },
51 | [drag]
52 | );
53 |
54 | const onEnd = useCallback(evt => {
55 | setDrag(null);
56 | console.log('onEnd', evt);
57 | }, []);
58 |
59 | const onCancel = useCallback(
60 | evt => {
61 | setDrag(null);
62 |
63 | if (drag) {
64 | const setPoint = points[drag.key][1];
65 | const point = drag.startPoint;
66 |
67 | setPoint(point);
68 | }
69 |
70 | setEnabled(true);
71 | console.log('onCancel', evt);
72 | },
73 | [drag]
74 | );
75 |
76 | useMemo(() => {
77 | setEnabled(panEnabled);
78 | }, [panEnabled]);
79 |
80 | const dragPoint = drag ? points[drag.key][0] : null;
81 |
82 | useMemo(() => {
83 | if (cancelsOut && enabled && dragPoint) {
84 | const maxPoint = {
85 | x: boxSize.width - 200,
86 | y: boxSize.height - 200,
87 | };
88 |
89 | if (
90 | dragPoint.x < 0 ||
91 | dragPoint.y < 0 ||
92 | dragPoint.x > maxPoint.x ||
93 | dragPoint.y > maxPoint.y
94 | ) {
95 | setEnabled(false);
96 | }
97 | }
98 | }, [cancelsOut, enabled, dragPoint, boxSize]);
99 |
100 | return (
101 |
102 |
113 |
123 |
124 |
125 |
You can drag me.
126 |
133 |
134 |
135 |
144 |
145 |
146 |
147 | Drag here
148 |
149 |
150 | You can only drag me by trigger.
151 |
152 |
153 |
154 |
155 |
156 | );
157 | };
158 |
159 | export default DraggableNotes;
160 |
161 | function getDraggableKey(target) {
162 | if (target.dataset) {
163 | if (target.dataset.draggable) {
164 | return target.dataset.draggable;
165 | }
166 |
167 | if (target.dataset.dragbox) {
168 | return null;
169 | }
170 | }
171 |
172 | if (target.parentNode) {
173 | return getDraggableKey(target.parentNode);
174 | }
175 |
176 | return null;
177 | }
178 |
179 | function convertTranslate(translate) {
180 | return {
181 | transform: `translate3d(${translate.x}px, ${translate.y}px, 0)`,
182 | WebkitTransform: `translate3d(${translate.x}px, ${translate.y}px, 0)`,
183 | msTransform: `translate(${translate.x}px, ${translate.y}px)`,
184 | };
185 | }
186 |
--------------------------------------------------------------------------------
/packages/pannable/src/utils/motion.ts:
--------------------------------------------------------------------------------
1 | import {
2 | XY,
3 | WH,
4 | Point,
5 | Size,
6 | Rect,
7 | Align,
8 | Bound,
9 | Inset,
10 | LT,
11 | RB,
12 | } from '../interfaces';
13 |
14 | function getAcc(rate: number, vel: Point): Point {
15 | const r = Math.sqrt(vel.x * vel.x + vel.y * vel.y);
16 |
17 | if (r === 0) {
18 | return { x: 0, y: 0 };
19 | }
20 | return { x: rate * (vel.x / r), y: rate * (vel.y / r) };
21 | }
22 |
23 | export function getAdjustedContentVelocity(velocity: Point): Point {
24 | function calculate(x: XY) {
25 | const maxVelocity = 2.5;
26 | return Math.max(-maxVelocity, Math.min(velocity[x], maxVelocity));
27 | }
28 |
29 | const adjustedVelocity: Point = {
30 | x: calculate('x'),
31 | y: calculate('y'),
32 | };
33 | if (adjustedVelocity.x === velocity.x && adjustedVelocity.y === velocity.y) {
34 | return velocity;
35 | }
36 | return adjustedVelocity;
37 | }
38 |
39 | export function getAdjustedContentOffset(
40 | offset: Point,
41 | size: Size,
42 | cSize: Size,
43 | cInset: Inset,
44 | bound: Record,
45 | paging: boolean
46 | ): Point {
47 | function calculate(x: XY) {
48 | const [width, left, right]: [WH, LT, RB] =
49 | x === 'x' ? ['width', 'left', 'right'] : ['height', 'top', 'bottom'];
50 | const sizeWidth = size[width];
51 | const offsetX = offset[x];
52 |
53 | if (bound[x] === -1) {
54 | return offsetX;
55 | }
56 |
57 | let minOffsetX = Math.min(
58 | sizeWidth - (cInset[left] + cSize[width] + cInset[right]),
59 | 0
60 | );
61 |
62 | if (paging && sizeWidth > 0) {
63 | minOffsetX = sizeWidth * Math.ceil(minOffsetX / sizeWidth);
64 | }
65 |
66 | return Math.max(minOffsetX, Math.min(offsetX, 0));
67 | }
68 |
69 | const adjustedOffset: Point = {
70 | x: calculate('x'),
71 | y: calculate('y'),
72 | };
73 |
74 | if (adjustedOffset.x === offset.x && adjustedOffset.y === offset.y) {
75 | return offset;
76 | }
77 |
78 | return adjustedOffset;
79 | }
80 |
81 | export function getAdjustedBounceOffset(
82 | offset: Point,
83 | size: Size,
84 | cSize: Size,
85 | cInset: Inset,
86 | bound: Record
87 | ): Point {
88 | function calculate(x: XY) {
89 | const [width, height, left, right]: [WH, WH, LT, RB] =
90 | x === 'x'
91 | ? ['width', 'height', 'left', 'right']
92 | : ['height', 'width', 'top', 'bottom'];
93 | const offsetX = offset[x];
94 | const boundX = bound[x];
95 |
96 | if (boundX === -1) {
97 | return offsetX;
98 | }
99 |
100 | const minOffsetX = Math.min(
101 | size[width] - (cInset[left] + cSize[width] + cInset[right]),
102 | 0
103 | );
104 | const maxDist = Math.min(size[width], size[height]) / 2;
105 |
106 | if (0 < offsetX) {
107 | if (boundX === 0) {
108 | return 0;
109 | }
110 | return maxDist * (1 - maxDist / (maxDist + offsetX));
111 | }
112 | if (offsetX < minOffsetX) {
113 | if (boundX === 0) {
114 | return minOffsetX;
115 | }
116 | return (
117 | minOffsetX - maxDist * (1 - maxDist / (maxDist - offsetX + minOffsetX))
118 | );
119 | }
120 |
121 | return offsetX;
122 | }
123 |
124 | const adjustedOffset: Point = {
125 | x: calculate('x'),
126 | y: calculate('y'),
127 | };
128 |
129 | if (adjustedOffset.x === offset.x && adjustedOffset.y === offset.y) {
130 | return offset;
131 | }
132 |
133 | return adjustedOffset;
134 | }
135 |
136 | export function getDecelerationEndOffset(
137 | offset: Point,
138 | velocity: Point,
139 | size: Size,
140 | paging: boolean,
141 | acc: Point | number
142 | ): Point {
143 | const accXY = typeof acc === 'number' ? getAcc(acc, velocity) : acc;
144 |
145 | function calculate(x: XY): number {
146 | const width = x === 'x' ? 'width' : 'height';
147 |
148 | let offsetX = offset[x];
149 | let velocityX = velocity[x];
150 |
151 | if (paging && size[width] > 0) {
152 | const minVelocity = 0.5;
153 | let delta = offsetX / size[width];
154 |
155 | if (minVelocity < velocityX) {
156 | delta = Math.ceil(delta);
157 | } else if (velocityX < -minVelocity) {
158 | delta = Math.floor(delta);
159 | } else {
160 | delta = Math.round(delta);
161 | }
162 |
163 | offsetX = size[width] * delta;
164 | } else {
165 | if (accXY[x]) {
166 | offsetX += (velocityX * (velocityX / accXY[x])) / 2;
167 | }
168 | }
169 |
170 | return offsetX;
171 | }
172 |
173 | return { x: calculate('x'), y: calculate('y') };
174 | }
175 |
176 | export function calculateOffsetForRect(
177 | rect: Rect,
178 | align: Record | Align,
179 | cOffset: Point,
180 | size: Size
181 | ): Point {
182 | const alignXY = typeof align === 'object' ? align : { x: align, y: align };
183 |
184 | function calculate(x: XY) {
185 | const width = x === 'x' ? 'width' : 'height';
186 |
187 | let offsetX = -rect[x];
188 | let alignX = alignXY[x];
189 | const delta = size[width] - rect[width];
190 |
191 | if (alignX === 'auto') {
192 | const direction = delta < 0 ? -1 : 1;
193 | const dOffsetX = cOffset[x] - offsetX;
194 |
195 | offsetX +=
196 | direction *
197 | Math.max(0, Math.min(direction * dOffsetX, direction * delta));
198 | } else {
199 | if (alignX === 'start') {
200 | alignX = 0;
201 | } else if (alignX === 'center') {
202 | alignX = 0.5;
203 | } else if (alignX === 'end') {
204 | alignX = 1;
205 | }
206 |
207 | offsetX += alignX * delta;
208 | }
209 |
210 | return offsetX;
211 | }
212 |
213 | return {
214 | x: calculate('x'),
215 | y: calculate('y'),
216 | };
217 | }
218 |
--------------------------------------------------------------------------------
/packages/pannable/src/pad/Pad.tsx:
--------------------------------------------------------------------------------
1 | import { PadState, PadEvent, PadMethods, PadScrollTo } from './padReducer';
2 | import PadInner from './PadInner';
3 | import { PannableState } from '../pannableReducer';
4 | import Pannable, { PannableEvent } from '../Pannable';
5 | import { XY, WH, LT, RB, Point, Size, Bound, Inset } from '../interfaces';
6 | import { useIsomorphicLayoutEffect } from '../utils/hooks';
7 | import React, { useMemo, useCallback, useRef } from 'react';
8 |
9 | export interface PadProps {
10 | width: number;
11 | height: number;
12 | pagingEnabled?: boolean;
13 | directionalLockEnabled?: boolean;
14 | boundX?: Bound;
15 | boundY?: Bound;
16 | contentInsetTop?: number;
17 | contentInsetRight?: number;
18 | contentInsetBottom?: number;
19 | contentInsetLeft?: number;
20 | contentStyle?: React.CSSProperties;
21 | onScroll?: (evt: PadEvent) => void;
22 | onStartDragging?: (evt: PadEvent) => void;
23 | onEndDragging?: (evt: PadEvent) => void;
24 | onStartDecelerating?: (evt: PadEvent) => void;
25 | onEndDecelerating?: (evt: PadEvent) => void;
26 | onResizeContent?: (evt: Size) => void;
27 | renderBackground?: (state: PadState, methods: PadMethods) => React.ReactNode;
28 | renderOverlay?: (state: PadState, methods: PadMethods) => React.ReactNode;
29 | render?: (state: PadState, methods: PadMethods) => React.ReactNode;
30 | scrollTo?: PadScrollTo | null;
31 | }
32 |
33 | export const Pad = React.memo<
34 | Omit, 'onScroll' | 'render'> & PadProps
35 | >((props) => {
36 | const {
37 | width,
38 | height,
39 | pagingEnabled = false,
40 | directionalLockEnabled = false,
41 | boundX = 1,
42 | boundY = 1,
43 | contentInsetTop = 0,
44 | contentInsetRight = 0,
45 | contentInsetBottom = 0,
46 | contentInsetLeft = 0,
47 | contentStyle,
48 | onScroll,
49 | onStartDragging,
50 | onEndDragging,
51 | onStartDecelerating,
52 | onEndDecelerating,
53 | onResizeContent,
54 | renderBackground,
55 | renderOverlay,
56 | render,
57 | scrollTo,
58 | children,
59 | ...pannableProps
60 | } = props;
61 | const size = useMemo(() => ({ width, height }), [width, height]);
62 | const bound = useMemo(() => ({ x: boundX, y: boundY }), [boundX, boundY]);
63 | const contentInset = useMemo(
64 | () => ({
65 | top: contentInsetTop,
66 | right: contentInsetRight,
67 | bottom: contentInsetBottom,
68 | left: contentInsetLeft,
69 | }),
70 | [contentInsetTop, contentInsetRight, contentInsetBottom, contentInsetLeft]
71 | );
72 | const stateRef = useRef();
73 | const methodsRef = useRef();
74 | const delegate = { shouldStart: pannableProps.shouldStart };
75 | const delegateRef = useRef(delegate);
76 | delegateRef.current = delegate;
77 |
78 | const pannableShouldStart = useCallback((evt: PannableEvent) => {
79 | let flag = true;
80 |
81 | if (delegateRef.current.shouldStart) {
82 | flag = delegateRef.current.shouldStart(evt);
83 | }
84 |
85 | if (flag) {
86 | const state = stateRef.current;
87 |
88 | if (state) {
89 | flag = shouldStartDrag(
90 | evt.velocity,
91 | state.size,
92 | state.contentSize,
93 | state.contentInset,
94 | state.bound
95 | );
96 | } else {
97 | flag = false;
98 | }
99 | }
100 |
101 | return flag;
102 | }, []);
103 |
104 | useIsomorphicLayoutEffect(() => {
105 | if (scrollTo) {
106 | const methods = methodsRef.current;
107 |
108 | if (methods) {
109 | methods.scrollTo(scrollTo);
110 | }
111 | }
112 | }, [scrollTo]);
113 |
114 | const pannableStyle = useMemo(() => {
115 | const style: React.CSSProperties = {
116 | overflow: 'hidden',
117 | position: 'relative',
118 | width: size.width,
119 | height: size.height,
120 | };
121 |
122 | if (pannableProps.style) {
123 | Object.assign(style, pannableProps.style);
124 | }
125 |
126 | return style;
127 | }, [size, pannableProps.style]);
128 |
129 | pannableProps.style = pannableStyle;
130 | pannableProps.shouldStart = pannableShouldStart;
131 |
132 | return (
133 | (
136 | {
153 | stateRef.current = state;
154 | methodsRef.current = methods;
155 |
156 | return render ? render(state, methods) : children;
157 | }}
158 | />
159 | )}
160 | />
161 | );
162 | });
163 |
164 | export default Pad;
165 |
166 | function shouldStartDrag(
167 | velocity: Point,
168 | size: Size,
169 | cSize: Size,
170 | cInset: Inset,
171 | bound: Record
172 | ): boolean {
173 | const [x, width, left, right]: [XY, WH, LT, RB] =
174 | Math.abs(velocity.y) < Math.abs(velocity.x)
175 | ? ['x', 'width', 'left', 'right']
176 | : ['y', 'height', 'top', 'bottom'];
177 |
178 | if (bound[x] !== 0) {
179 | return true;
180 | }
181 |
182 | return size[width] < cInset[left] + cSize[width] + cInset[right];
183 | }
184 |
--------------------------------------------------------------------------------
/packages/website/static/img/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/packages/pannable/src/pad/PadInner.tsx:
--------------------------------------------------------------------------------
1 | import PadContext from './PadContext';
2 | import reducer, {
3 | initialPadState,
4 | PadState,
5 | PadEvent,
6 | PadMethods,
7 | } from './padReducer';
8 | import { PannableState } from '../pannableReducer';
9 | import { XY, Size, Bound, Inset, Point } from '../interfaces';
10 | import {
11 | requestAnimationFrame,
12 | cancelAnimationFrame,
13 | } from '../utils/animationFrame';
14 | import { useIsomorphicLayoutEffect } from '../utils/hooks';
15 | import React, { useMemo, useReducer, useRef, useCallback } from 'react';
16 |
17 | function convertTransformTranslate(translate: Point): React.CSSProperties {
18 | return {
19 | WebkitTransform: `translate3d(${translate.x}px, ${translate.y}px, 0)`,
20 | msTransform: `translate(${translate.x}px, ${translate.y}px)`,
21 | transform: `translate3d(${translate.x}px, ${translate.y}px, 0)`,
22 | };
23 | }
24 |
25 | export interface PadInnerProps {
26 | pannable: PannableState;
27 | size: Size;
28 | pagingEnabled: boolean;
29 | directionalLockEnabled: boolean;
30 | bound: Record;
31 | contentInset: Inset;
32 | contentStyle?: React.CSSProperties;
33 | onScroll?: (evt: PadEvent) => void;
34 | onStartDragging?: (evt: PadEvent) => void;
35 | onEndDragging?: (evt: PadEvent) => void;
36 | onStartDecelerating?: (evt: PadEvent) => void;
37 | onEndDecelerating?: (evt: PadEvent) => void;
38 | onResizeContent?: (evt: Size) => void;
39 | renderBackground?: (state: PadState, methods: PadMethods) => React.ReactNode;
40 | renderOverlay?: (state: PadState, methods: PadMethods) => React.ReactNode;
41 | render: (state: PadState, methods: PadMethods) => React.ReactNode;
42 | }
43 |
44 | export const PadInner = React.memo((props) => {
45 | const {
46 | pannable,
47 | size,
48 | pagingEnabled,
49 | directionalLockEnabled,
50 | bound,
51 | contentInset,
52 | contentStyle,
53 | onScroll,
54 | onStartDragging,
55 | onEndDragging,
56 | onStartDecelerating,
57 | onEndDecelerating,
58 | onResizeContent,
59 | renderBackground,
60 | renderOverlay,
61 | render,
62 | } = props;
63 | const [state, dispatch] = useReducer(reducer, initialPadState);
64 | const prevStateRef = useRef(state);
65 | const delegate = {
66 | onScroll,
67 | onStartDragging,
68 | onEndDragging,
69 | onStartDecelerating,
70 | onEndDecelerating,
71 | onResizeContent,
72 | };
73 | const delegateRef = useRef(delegate);
74 | delegateRef.current = delegate;
75 |
76 | const methodsRef = useRef({
77 | scrollTo(params) {
78 | dispatch({ type: 'scrollTo', payload: params });
79 | },
80 | });
81 |
82 | const contentOnResize = useCallback((contentSize: Size) => {
83 | dispatch({ type: 'setState', payload: { contentSize } });
84 | }, []);
85 |
86 | useIsomorphicLayoutEffect(() => {
87 | dispatch({ type: 'setState', payload: { pannable } });
88 | }, [pannable]);
89 |
90 | useIsomorphicLayoutEffect(() => {
91 | dispatch({
92 | type: 'setState',
93 | payload: {
94 | size,
95 | bound,
96 | contentInset,
97 | pagingEnabled,
98 | directionalLockEnabled,
99 | },
100 | });
101 | }, [size, bound, contentInset, pagingEnabled, directionalLockEnabled]);
102 |
103 | useIsomorphicLayoutEffect(() => {
104 | const prevState = prevStateRef.current;
105 | prevStateRef.current = state;
106 |
107 | if (prevState.pannable.translation !== state.pannable.translation) {
108 | if (state.pannable.translation) {
109 | if (prevState.pannable.translation) {
110 | dispatch({ type: 'dragMove' });
111 | } else {
112 | dispatch({ type: 'dragStart' });
113 | }
114 | } else {
115 | if (state.pannable.cancelled) {
116 | dispatch({ type: 'dragCancel' });
117 | } else {
118 | dispatch({ type: 'dragEnd' });
119 | }
120 | }
121 | }
122 |
123 | if (prevState.contentSize !== state.contentSize) {
124 | if (delegateRef.current.onResizeContent) {
125 | delegateRef.current.onResizeContent(state.contentSize);
126 | }
127 | }
128 |
129 | const evt: PadEvent = {
130 | size: state.size,
131 | contentSize: state.contentSize,
132 | contentInset: state.contentInset,
133 | contentOffset: state.contentOffset,
134 | contentVelocity: state.contentVelocity,
135 | dragging: !!state.drag,
136 | decelerating: !!state.deceleration,
137 | };
138 |
139 | if (prevState.contentOffset !== state.contentOffset) {
140 | if (delegateRef.current.onScroll) {
141 | delegateRef.current.onScroll(evt);
142 | }
143 | }
144 | if (prevState.drag !== state.drag) {
145 | if (!prevState.drag) {
146 | if (delegateRef.current.onStartDragging) {
147 | delegateRef.current.onStartDragging(evt);
148 | }
149 | } else if (!state.drag) {
150 | if (delegateRef.current.onEndDragging) {
151 | delegateRef.current.onEndDragging(evt);
152 | }
153 | }
154 | }
155 | if (prevState.deceleration !== state.deceleration) {
156 | if (!prevState.deceleration) {
157 | if (delegateRef.current.onStartDecelerating) {
158 | delegateRef.current.onStartDecelerating(evt);
159 | }
160 | } else if (!state.deceleration) {
161 | if (delegateRef.current.onEndDecelerating) {
162 | delegateRef.current.onEndDecelerating(evt);
163 | }
164 | }
165 | }
166 |
167 | if (!state.deceleration) {
168 | return;
169 | }
170 |
171 | const timer = requestAnimationFrame(() => {
172 | dispatch({ type: 'decelerate' });
173 | });
174 |
175 | return () => {
176 | cancelAnimationFrame(timer);
177 | };
178 | }, [state]);
179 |
180 | const backgroundLayer = renderBackground
181 | ? renderBackground(state, methodsRef.current)
182 | : null;
183 | const overlayLayer = renderOverlay
184 | ? renderOverlay(state, methodsRef.current)
185 | : null;
186 | const contentLayer = render(state, methodsRef.current);
187 |
188 | const style = useMemo(
189 | () =>
190 | ({
191 | willChange: 'transform',
192 | overflow: 'hidden',
193 | position: 'absolute',
194 | left: state.contentInset.left,
195 | top: state.contentInset.top,
196 | width: state.contentSize.width,
197 | height: state.contentSize.height,
198 | ...convertTransformTranslate(state.contentOffset),
199 | ...contentStyle,
200 | } as React.CSSProperties),
201 | [state.contentOffset, state.contentSize, state.contentInset, contentStyle]
202 | );
203 |
204 | const contextValue = useMemo(
205 | () => ({
206 | visibleRect: {
207 | x: -state.contentOffset.x,
208 | y: -state.contentOffset.y,
209 | width: state.size.width,
210 | height: state.size.height,
211 | },
212 | onResize: contentOnResize,
213 | }),
214 | [state.contentOffset, state.size, contentOnResize]
215 | );
216 |
217 | return (
218 | <>
219 | {backgroundLayer}
220 |
221 |
222 | {contentLayer}
223 |
224 |
225 | {overlayLayer}
226 | >
227 | );
228 | });
229 |
230 | export default PadInner;
231 |
--------------------------------------------------------------------------------
/packages/pannable/src/pad/GridContent.tsx:
--------------------------------------------------------------------------------
1 | import PadContext from './PadContext';
2 | import { XY, RC, WH, Rect, Size } from '../interfaces';
3 | import { useIsomorphicLayoutEffect } from '../utils/hooks';
4 | import { getItemVisibleRect, needsRender } from '../utils/visible';
5 | import { isEqualToSize } from '../utils/geometry';
6 | import React, { useContext, useMemo, useRef } from 'react';
7 |
8 | function itemOnResize() {}
9 |
10 | export interface GridItemProps {
11 | forceRender?: boolean;
12 | }
13 | const Item = React.memo((props) => null);
14 |
15 | export type GridLayoutItem = {
16 | rect: Rect;
17 | rowIndex: number;
18 | columnIndex: number;
19 | itemIndex: number;
20 | };
21 | export type GridLayout = {
22 | size: Size;
23 | count: Record;
24 | layoutList: GridLayoutItem[];
25 | };
26 | export type GridLayoutAttrs = GridLayoutItem & {
27 | visibleRect: Rect;
28 | needsRender: boolean;
29 | Item: React.FC;
30 | };
31 |
32 | export interface GridContentProps {
33 | itemWidth: number;
34 | itemHeight: number;
35 | itemCount: number;
36 | renderItem: (attrs: GridLayoutAttrs) => React.ReactNode;
37 | direction?: XY;
38 | width?: number;
39 | height?: number;
40 | rowSpacing?: number;
41 | columnSpacing?: number;
42 | render?: (layout: GridLayout) => React.ReactNode;
43 | }
44 |
45 | export const GridContent = React.memo<
46 | React.ComponentProps<'div'> & GridContentProps
47 | >((props) => {
48 | const {
49 | itemWidth,
50 | itemHeight,
51 | itemCount,
52 | renderItem,
53 | direction = 'y',
54 | rowSpacing = 0,
55 | columnSpacing = 0,
56 | width,
57 | height,
58 | render,
59 | children,
60 | ...divProps
61 | } = props;
62 | const context = useContext(PadContext);
63 |
64 | const fixedWidth = width ?? context.width;
65 | const fixedHeight = height ?? context.height;
66 | const layout = useMemo(
67 | () =>
68 | calculateLayout({
69 | direction,
70 | size: { width: fixedWidth, height: fixedHeight },
71 | spacing: { row: rowSpacing, column: columnSpacing },
72 | itemSize: { width: itemWidth, height: itemHeight },
73 | itemCount,
74 | }),
75 | [
76 | direction,
77 | fixedWidth,
78 | fixedHeight,
79 | rowSpacing,
80 | columnSpacing,
81 | itemWidth,
82 | itemHeight,
83 | itemCount,
84 | ]
85 | );
86 |
87 | const prevSizeRef = useRef();
88 | const delegate = { onResize: context.onResize };
89 | const delegateRef = useRef(delegate);
90 | delegateRef.current = delegate;
91 |
92 | useIsomorphicLayoutEffect(() => {
93 | const prevSize = prevSizeRef.current;
94 | prevSizeRef.current = layout.size;
95 |
96 | if (!isEqualToSize(prevSize, layout.size)) {
97 | delegateRef.current.onResize(layout.size);
98 | }
99 | }, [layout.size]);
100 |
101 | function buildItem(attrs: GridLayoutAttrs): React.ReactNode {
102 | const { rect, itemIndex, visibleRect, needsRender, Item } = attrs;
103 | let forceRender = false;
104 | let elem = renderItem(attrs);
105 |
106 | let key: React.Key = 'GridContent_' + itemIndex;
107 |
108 | if (React.isValidElement(elem) && elem.type === Item) {
109 | if (elem.key) {
110 | key = elem.key;
111 | }
112 |
113 | const itemProps: GridItemProps = elem.props;
114 |
115 | if (itemProps.forceRender !== undefined) {
116 | forceRender = itemProps.forceRender;
117 | }
118 |
119 | elem = elem.props.children;
120 | }
121 |
122 | if (!needsRender && !forceRender) {
123 | return null;
124 | }
125 |
126 | const itemStyle: React.CSSProperties = {
127 | position: 'absolute',
128 | left: rect.x,
129 | top: rect.y,
130 | width: rect.width,
131 | height: rect.height,
132 | };
133 |
134 | return (
135 |
145 | {elem}
146 |
147 | );
148 | }
149 |
150 | const items = layout.layoutList.map((attrs) =>
151 | buildItem({
152 | ...attrs,
153 | visibleRect: getItemVisibleRect(attrs.rect, context.visibleRect),
154 | needsRender: needsRender(attrs.rect, context.visibleRect),
155 | Item,
156 | })
157 | );
158 |
159 | if (render) {
160 | render(layout);
161 | }
162 |
163 | const divStyle = useMemo(() => {
164 | const style: React.CSSProperties = {
165 | position: 'relative',
166 | overflow: 'hidden',
167 | };
168 |
169 | if (layout.size) {
170 | style.width = layout.size.width;
171 | style.height = layout.size.height;
172 | }
173 |
174 | if (divProps.style) {
175 | Object.assign(style, divProps.style);
176 | }
177 |
178 | return style;
179 | }, [layout.size, divProps.style]);
180 |
181 | divProps.style = divStyle;
182 |
183 | return {items}
;
184 | });
185 |
186 | export default GridContent;
187 |
188 | function calculateLayout(options: {
189 | direction: XY;
190 | size: Partial;
191 | spacing: Record;
192 | itemSize: Size;
193 | itemCount: number;
194 | }): GridLayout {
195 | const { direction, size, spacing, itemSize, itemCount } = options;
196 |
197 | const [x, y, width, height, row, column]: [XY, XY, WH, WH, RC, RC] =
198 | direction === 'x'
199 | ? ['y', 'x', 'height', 'width', 'column', 'row']
200 | : ['x', 'y', 'width', 'height', 'row', 'column'];
201 |
202 | let sizeWidth = size[width];
203 | let sizeHeight = 0;
204 | let countRow = 0;
205 | let countColumn = 0;
206 | const layoutList: GridLayoutItem[] = [];
207 |
208 | if (sizeWidth === undefined) {
209 | countColumn = itemCount;
210 |
211 | if (itemSize[width] === 0) {
212 | sizeWidth = 0;
213 | } else {
214 | sizeWidth = itemCount * itemSize[width];
215 |
216 | if (itemCount > 1) {
217 | sizeWidth += (itemCount - 1) * spacing[column];
218 | }
219 | }
220 | } else {
221 | if (itemSize[width] === 0) {
222 | countColumn = itemCount;
223 | } else {
224 | countColumn = 1;
225 |
226 | if (itemSize[width] < sizeWidth) {
227 | countColumn += Math.floor(
228 | (sizeWidth - itemSize[width]) / (itemSize[width] + spacing[column])
229 | );
230 | }
231 | }
232 | }
233 |
234 | if (countColumn > 0) {
235 | countRow = Math.ceil(itemCount / countColumn);
236 | }
237 |
238 | for (let rowIndex = 0; rowIndex < countRow; rowIndex++) {
239 | if (rowIndex > 0) {
240 | sizeHeight += spacing[row];
241 | }
242 |
243 | for (let columnIndex = 0; columnIndex < countColumn; columnIndex++) {
244 | const itemIndex = columnIndex + rowIndex * countColumn;
245 | let attrX = 0;
246 |
247 | if (countColumn > 1) {
248 | attrX += Math.round(
249 | columnIndex * ((sizeWidth - itemSize[width]) / (countColumn - 1))
250 | );
251 | }
252 |
253 | if (itemIndex >= itemCount) {
254 | break;
255 | }
256 |
257 | layoutList.push({
258 | rect: {
259 | [x]: attrX,
260 | [y]: sizeHeight,
261 | [width]: itemSize[width],
262 | [height]: itemSize[height],
263 | },
264 | [row + 'Index']: rowIndex,
265 | [column + 'Index']: columnIndex,
266 | itemIndex,
267 | } as GridLayoutItem);
268 | }
269 |
270 | sizeHeight += itemSize[height];
271 | }
272 |
273 | return {
274 | size: { [width]: sizeWidth, [height]: sizeHeight } as Size,
275 | count: { [row]: countRow, [column]: countColumn } as Record,
276 | layoutList,
277 | };
278 | }
279 |
--------------------------------------------------------------------------------
/packages/demo/src/stories/carousel/index.stories.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useCallback, useMemo } from 'react';
2 | import { withKnobs, object, boolean, select } from '@storybook/addon-knobs';
3 | import { AutoResizing, Carousel, Loop, ItemContent } from 'react-pannable';
4 | import HorizontalIndicator from './HorizontalIndicator';
5 | import VerticalIndicator from './VerticalIndicator';
6 | import '../../ui/overview.css';
7 | import photo1 from './photo1.jpg';
8 | import photo2 from './photo2.jpg';
9 | import photo3 from './photo3.jpg';
10 | import photo4 from './photo4.jpg';
11 | import photo5 from './photo5.jpg';
12 |
13 | export default {
14 | title: 'Carousel',
15 | decorators: [withKnobs],
16 | };
17 |
18 | const data = [
19 | { url: photo1 },
20 | { url: photo2 },
21 | { url: photo3 },
22 | { url: photo4 },
23 | { url: photo5 },
24 | ];
25 |
26 | export const LoopDemo = () => {
27 | const direction = select('direction', { x: 'x', y: 'y' }, 'x', 'props');
28 | const scrollType = select(
29 | 'Scroll Action',
30 | { null: '', scrollTo: 'scrollTo' },
31 | '',
32 | 'Scrolling'
33 | );
34 |
35 | let point;
36 | let rect;
37 | let align;
38 | let animated;
39 |
40 | if (scrollType === 'scrollTo') {
41 | point = object('point', undefined, 'Scrolling');
42 | rect = object('rect', undefined, 'Scrolling');
43 | align = select(
44 | 'align',
45 | { auto: 'auto', center: 'center', end: 'end', start: 'start' },
46 | 'start',
47 | 'Scrolling'
48 | );
49 | animated = boolean('animated ', true, 'Scrolling');
50 | }
51 |
52 | const scrollTo = useMemo(() => {
53 | if (scrollType !== 'scrollTo') {
54 | return null;
55 | }
56 |
57 | return { point, rect, align, animated };
58 | }, [scrollType, point, rect, align, animated]);
59 |
60 | return (
61 |
62 |
Loop
63 |
64 | Loop component used to play a number of looping items in sequence.
65 |
66 |
67 |
{
69 | const height = Math.ceil((width * 8) / 15.0);
70 |
71 | return (
72 |
78 |
79 |
85 |
86 |
87 | );
88 | }}
89 | />
90 |
91 |
92 | );
93 | };
94 |
95 | export const HorizontalCarousel = () => {
96 | const loop = boolean('loop', true, 'props');
97 | const autoplayEnabled = boolean('autoplayEnabled', true, 'props');
98 | const autoplayInterval = select(
99 | 'autoplayInterval',
100 | {
101 | '5000': 5000,
102 | '3000': 3000,
103 | '1000': 1000,
104 | },
105 | 5000,
106 | 'props'
107 | );
108 | const list = object('Data List', data);
109 |
110 | const [scrollToIndex, setScrollToIndex] = useState(null);
111 |
112 | const renderItem = useCallback(
113 | ({ itemIndex }) => {
114 | const item = list[itemIndex];
115 |
116 | return ;
117 | },
118 | [list]
119 | );
120 |
121 | const onIndicatorPrev = useCallback(() => {
122 | setScrollToIndex({
123 | index: ({ activeIndex }) => activeIndex - 1,
124 | animated: true,
125 | });
126 | }, []);
127 |
128 | const onIndicatorNext = useCallback(() => {
129 | setScrollToIndex({
130 | index: ({ activeIndex }) => activeIndex + 1,
131 | animated: true,
132 | });
133 | }, []);
134 |
135 | const onIndicatorGoto = useCallback((index) => {
136 | setScrollToIndex({ index, animated: true });
137 | }, []);
138 |
139 | return (
140 |
141 |
Carousel
142 |
143 | Carousel component used to play a number of looping items in sequence.
144 |
145 |
146 |
{
148 | const height = Math.ceil((width * 8) / 15.0);
149 |
150 | return (
151 | (
162 |
169 | )}
170 | />
171 | );
172 | }}
173 | />
174 |
175 |
176 | );
177 | };
178 |
179 | // export const VerticalCarousel = () => {
180 | // const loop = boolean('loop', true, 'props');
181 | // const autoplayEnabled = boolean('autoplayEnabled', true, 'props');
182 | // const autoplayInterval = select(
183 | // 'autoplayInterval',
184 | // {
185 | // '5000': 5000,
186 | // '3000': 3000,
187 | // '1000': 1000,
188 | // },
189 | // 5000,
190 | // 'props'
191 | // );
192 | // const list = object('Data List', data);
193 |
194 | // const [scrollToIndex, setScrollToIndex] = useState(null);
195 |
196 | // const renderItem = useCallback(
197 | // ({ itemIndex }) => {
198 | // const item = list[itemIndex];
199 |
200 | // return ;
201 | // },
202 | // [list]
203 | // );
204 |
205 | // const onIndicatorGoto = useCallback(index => {
206 | // setScrollToIndex({ index, animated: true });
207 | // }, []);
208 |
209 | // return (
210 | //
211 | //
Carousel
212 | //
213 | // Carousel component used to play a number of looping items in sequence.
214 | //
215 | //
216 | //
217 | // {({ width }) => {
218 | // const height = Math.ceil((width * 8) / 15.0);
219 |
220 | // return (
221 | //
232 | // {({ activeIndex }) => (
233 | //
240 | // )}
241 | //
242 | // );
243 | // }}
244 | //
245 | //
246 | //
247 | // );
248 | // };
249 |
--------------------------------------------------------------------------------
/packages/pannable/src/pad/ListContent.tsx:
--------------------------------------------------------------------------------
1 | import PadContext from './PadContext';
2 | import { XY, WH, Rect, Size } from '../interfaces';
3 | import { useIsomorphicLayoutEffect } from '../utils/hooks';
4 | import { getItemVisibleRect, needsRender } from '../utils/visible';
5 | import { isEqualToSize } from '../utils/geometry';
6 | import React, { useContext, useMemo, useRef, useState } from 'react';
7 |
8 | type Hash = string | number;
9 |
10 | export interface ListItemProps {
11 | forceRender?: boolean;
12 | hash?: Hash;
13 | style?: React.CSSProperties;
14 | }
15 | const Item = React.memo((props) => null);
16 |
17 | export type ListLayoutItem = {
18 | rect: Rect;
19 | itemIndex: number;
20 | itemHash: Hash;
21 | itemSize?: Size;
22 | };
23 |
24 | export type ListLayout = {
25 | size: Size;
26 | layoutList: ListLayoutItem[];
27 | };
28 |
29 | export type ListLayoutAttrs = ListLayoutItem & {
30 | visibleRect: Rect;
31 | needsRender: boolean;
32 | Item: React.FC;
33 | };
34 |
35 | export interface ListContentProps {
36 | itemCount: number;
37 | renderItem: (attrs: ListLayoutAttrs) => React.ReactNode;
38 | direction?: XY;
39 | width?: number;
40 | height?: number;
41 | spacing?: number;
42 | estimatedItemWidth?: number | ((itemIndex: number) => number);
43 | estimatedItemHeight?: number | ((itemIndex: number) => number);
44 | render?: (layout: ListLayout) => React.ReactNode;
45 | }
46 |
47 | export const ListContent = React.memo<
48 | React.ComponentProps<'div'> & ListContentProps
49 | >((props) => {
50 | const {
51 | itemCount,
52 | renderItem,
53 | direction = 'y',
54 | width,
55 | height,
56 | spacing = 0,
57 | estimatedItemWidth = 0,
58 | estimatedItemHeight = 0,
59 | render,
60 | children,
61 | ...divProps
62 | } = props;
63 | const context = useContext(PadContext);
64 |
65 | const fixedWidth = width ?? context.width;
66 | const fixedHeight = height ?? context.height;
67 | const [itemHashList, setItemHashList] = useState([]);
68 | const [itemSizeDict, setItemSizeDict] = useState>({});
69 | const layout = useMemo(
70 | () =>
71 | calculateLayout(
72 | {
73 | direction,
74 | size: {
75 | width: fixedWidth,
76 | height: fixedHeight,
77 | },
78 | spacing,
79 | estimatedItemSize: {
80 | width: estimatedItemWidth,
81 | height: estimatedItemHeight,
82 | },
83 | itemCount,
84 | },
85 | itemHashList,
86 | itemSizeDict
87 | ),
88 | [
89 | fixedWidth,
90 | fixedHeight,
91 | direction,
92 | spacing,
93 | estimatedItemWidth,
94 | estimatedItemHeight,
95 | itemCount,
96 | itemHashList,
97 | itemSizeDict,
98 | ]
99 | );
100 | const prevSizeRef = useRef();
101 | const delegate = { onResize: context.onResize };
102 | const delegateRef = useRef(delegate);
103 | delegateRef.current = delegate;
104 |
105 | useIsomorphicLayoutEffect(() => {
106 | const prevSize = prevSizeRef.current;
107 | prevSizeRef.current = layout.size;
108 |
109 | if (!isEqualToSize(prevSize, layout.size)) {
110 | delegateRef.current.onResize(layout.size);
111 | }
112 | }, [layout.size]);
113 |
114 | const nextItemHashList: Hash[] = [];
115 |
116 | function buildItem(attrs: ListLayoutAttrs): React.ReactNode {
117 | const { rect, itemIndex, itemSize, visibleRect, needsRender, Item } = attrs;
118 | let forceRender = false;
119 | let element = renderItem(attrs);
120 |
121 | let key: React.Key = `ListContent_` + itemIndex;
122 | let hash: Hash | null = null;
123 | const itemStyle: React.CSSProperties = {
124 | position: 'absolute',
125 | left: rect.x,
126 | top: rect.y,
127 | width: rect.width,
128 | height: rect.height,
129 | };
130 |
131 | if (React.isValidElement(element) && element.type === Item) {
132 | if (element.key) {
133 | key = element.key;
134 | }
135 |
136 | const itemProps: ListItemProps = element.props;
137 |
138 | if (itemProps.forceRender !== undefined) {
139 | forceRender = itemProps.forceRender;
140 | }
141 | if (itemProps.hash !== undefined) {
142 | hash = itemProps.hash;
143 | }
144 | if (itemProps.style !== undefined) {
145 | Object.assign(itemStyle, itemProps.style);
146 | }
147 |
148 | element = element.props.children;
149 | }
150 |
151 | if (hash === null) {
152 | hash = key;
153 | }
154 |
155 | let skipRender = !needsRender && !forceRender;
156 |
157 | if (!itemSize && nextItemHashList.indexOf(hash) !== -1) {
158 | skipRender = true;
159 | }
160 |
161 | nextItemHashList[itemIndex] = hash;
162 |
163 | if (skipRender) {
164 | return null;
165 | }
166 |
167 | const contextValue = { ...context };
168 |
169 | contextValue.visibleRect = visibleRect;
170 | contextValue.onResize = (itemSize) => {
171 | setItemSizeDict((itemSizeDict) => {
172 | if (!hash) {
173 | return itemSizeDict;
174 | }
175 |
176 | if (isEqualToSize(itemSizeDict[hash], itemSize)) {
177 | return itemSizeDict;
178 | }
179 |
180 | return { ...itemSizeDict, [hash]: itemSize };
181 | });
182 | };
183 |
184 | if (direction === 'x') {
185 | contextValue.height = layout.size.height;
186 | } else {
187 | contextValue.width = layout.size.width;
188 | }
189 |
190 | return (
191 |
192 |
193 | {element}
194 |
195 |
196 | );
197 | }
198 |
199 | const items = layout.layoutList.map((attrs) =>
200 | buildItem({
201 | ...attrs,
202 | visibleRect: getItemVisibleRect(attrs.rect, context.visibleRect),
203 | needsRender: needsRender(attrs.rect, context.visibleRect),
204 | Item,
205 | })
206 | );
207 |
208 | if (!isEqualToArray(itemHashList, nextItemHashList)) {
209 | setItemHashList(nextItemHashList);
210 | }
211 |
212 | if (render) {
213 | render(layout);
214 | }
215 |
216 | const divStyle = useMemo(() => {
217 | const style: React.CSSProperties = {
218 | position: 'relative',
219 | overflow: 'hidden',
220 | };
221 |
222 | if (layout.size) {
223 | style.width = layout.size.width;
224 | style.height = layout.size.height;
225 | }
226 |
227 | if (divProps.style) {
228 | Object.assign(style, divProps.style);
229 | }
230 |
231 | return style;
232 | }, [layout.size, divProps.style]);
233 |
234 | divProps.style = divStyle;
235 |
236 | return {items}
;
237 | });
238 |
239 | export default ListContent;
240 |
241 | function calculateLayout(
242 | options: {
243 | direction: XY;
244 | size: Partial;
245 | spacing: number;
246 | estimatedItemSize: Record number)>;
247 | itemCount: number;
248 | },
249 | itemHashList: Hash[],
250 | itemSizeDict: Record
251 | ): ListLayout {
252 | const { direction, size, spacing, estimatedItemSize, itemCount } = options;
253 |
254 | const [x, y, width, height]: [XY, XY, WH, WH] =
255 | direction === 'x'
256 | ? ['y', 'x', 'height', 'width']
257 | : ['x', 'y', 'width', 'height'];
258 |
259 | let sizeWidth = 0;
260 | let sizeHeight = 0;
261 | const layoutList: ListLayoutItem[] = [];
262 | const fixed: Partial = {};
263 |
264 | if (size[width] !== undefined) {
265 | fixed[width] = size[width];
266 | }
267 |
268 | for (let itemIndex = 0; itemIndex < itemCount; itemIndex++) {
269 | const itemHash = itemHashList[itemIndex] || null;
270 | const itemSize = (itemHash && itemSizeDict[itemHash]) || null;
271 | const rect = { [x]: 0, [y]: sizeHeight };
272 |
273 | if (itemSize) {
274 | Object.assign(rect, itemSize);
275 | } else {
276 | const eisWidth = estimatedItemSize[width];
277 | const eisHeight = estimatedItemSize[height];
278 |
279 | rect[width] =
280 | fixed[width] ??
281 | (typeof eisWidth === 'function' ? eisWidth(itemIndex) : eisWidth);
282 |
283 | rect[height] =
284 | typeof eisHeight === 'function' ? eisHeight(itemIndex) : eisHeight;
285 | }
286 |
287 | layoutList.push({ rect, itemIndex, itemHash, itemSize } as ListLayoutItem);
288 |
289 | if (rect[height] > 0) {
290 | sizeHeight += rect[height];
291 |
292 | if (itemIndex < itemCount - 1) {
293 | sizeHeight += spacing;
294 | }
295 | }
296 | if (sizeWidth < rect[width]) {
297 | sizeWidth = rect[width];
298 | }
299 | }
300 |
301 | return {
302 | size: {
303 | [width]: fixed[width] ?? sizeWidth,
304 | [height]: fixed[height] ?? sizeHeight,
305 | } as Size,
306 | layoutList,
307 | };
308 | }
309 |
310 | function isEqualToArray(a1: any[], a2: any[]): boolean {
311 | if (!a1 || !a2) {
312 | return false;
313 | }
314 | if (a1 === a2) {
315 | return true;
316 | }
317 | if (a1.length !== a2.length) {
318 | return false;
319 | }
320 | for (let idx = 0; idx < a1.length; idx++) {
321 | if (a1[idx] !== a2[idx]) {
322 | return false;
323 | }
324 | }
325 |
326 | return true;
327 | }
328 |
--------------------------------------------------------------------------------
/packages/pannable/src/Pannable.tsx:
--------------------------------------------------------------------------------
1 | import reducer, {
2 | initialPannableState,
3 | PannableState,
4 | } from './pannableReducer';
5 | import { Point } from './interfaces';
6 | import { useIsomorphicLayoutEffect } from './utils/hooks';
7 | import subscribeEvent from './utils/subscribeEvent';
8 | import React, { useMemo, useRef, useReducer } from 'react';
9 |
10 | const supportsTouch =
11 | typeof window !== 'undefined' ? 'ontouchstart' in window : false;
12 |
13 | const MIN_START_DISTANCE = 0;
14 |
15 | export type PannableEvent = {
16 | target: EventTarget;
17 | translation: Point;
18 | velocity: Point;
19 | interval: number;
20 | };
21 |
22 | export type PannableTrackEvent = {
23 | target: EventTarget;
24 | translation: Point | null;
25 | velocity: Point;
26 | interval: number;
27 | };
28 |
29 | export interface PannableProps {
30 | disabled?: boolean;
31 | shouldStart?: (evt: PannableEvent) => boolean;
32 | onTrackStart?: (evt: PannableTrackEvent) => void;
33 | onTrackEnd?: (evt: PannableTrackEvent) => void;
34 | onTrackCancel?: (evt: PannableTrackEvent) => void;
35 | onStart?: (evt: PannableEvent) => void;
36 | onMove?: (evt: PannableEvent) => void;
37 | onEnd?: (evt: PannableEvent) => void;
38 | onCancel?: (evt: PannableEvent) => void;
39 | render?: (state: PannableState) => React.ReactNode;
40 | }
41 |
42 | export const Pannable = React.memo & PannableProps>(
43 | (props) => {
44 | const {
45 | disabled,
46 | shouldStart,
47 | onTrackStart,
48 | onTrackEnd,
49 | onTrackCancel,
50 | onStart,
51 | onMove,
52 | onEnd,
53 | onCancel,
54 | render,
55 | children,
56 | ...divProps
57 | } = props;
58 | const [state, dispatch] = useReducer(reducer, initialPannableState);
59 | const prevStateRef = useRef(state);
60 | const elemRef = useRef(null);
61 |
62 | const isTracking = !!state.target;
63 | const isMoving = !!state.translation;
64 |
65 | useIsomorphicLayoutEffect(() => {
66 | const prevState = prevStateRef.current;
67 | prevStateRef.current = state;
68 |
69 | if (state.target === null) {
70 | if (prevState.target) {
71 | if (prevState.translation) {
72 | const evt: PannableEvent = {
73 | target: prevState.target,
74 | translation: prevState.translation,
75 | velocity: prevState.velocity,
76 | interval: prevState.interval,
77 | };
78 |
79 | if (state.cancelled) {
80 | if (onCancel) {
81 | onCancel(evt);
82 | }
83 | } else {
84 | if (onEnd) {
85 | onEnd(evt);
86 | }
87 | }
88 | }
89 |
90 | const trackEvt: PannableTrackEvent = {
91 | target: prevState.target,
92 | translation: prevState.translation,
93 | velocity: prevState.velocity,
94 | interval: prevState.interval,
95 | };
96 |
97 | if (state.cancelled) {
98 | if (onTrackCancel) {
99 | onTrackCancel(trackEvt);
100 | }
101 | } else {
102 | if (onTrackEnd) {
103 | onTrackEnd(trackEvt);
104 | }
105 | }
106 | }
107 | } else {
108 | if (prevState.target === null) {
109 | const trackEvt: PannableTrackEvent = {
110 | target: state.target,
111 | translation: state.translation,
112 | velocity: state.velocity,
113 | interval: state.interval,
114 | };
115 |
116 | if (onTrackStart) {
117 | onTrackStart(trackEvt);
118 | }
119 | }
120 |
121 | if (state.translation === null) {
122 | const translation = {
123 | x: state.movePoint.x - state.startPoint.x,
124 | y: state.movePoint.y - state.startPoint.y,
125 | };
126 | const dist = Math.sqrt(
127 | Math.pow(translation.x, 2) + Math.pow(translation.y, 2)
128 | );
129 |
130 | if (dist > MIN_START_DISTANCE) {
131 | const evt: PannableEvent = {
132 | target: state.target,
133 | translation,
134 | velocity: state.velocity,
135 | interval: state.interval,
136 | };
137 |
138 | if (shouldStart) {
139 | if (shouldStart(evt)) {
140 | dispatch({ type: 'start' });
141 | }
142 | } else {
143 | dispatch({ type: 'start' });
144 | }
145 | }
146 | } else {
147 | const evt: PannableEvent = {
148 | target: state.target,
149 | translation: state.translation,
150 | velocity: state.velocity,
151 | interval: state.interval,
152 | };
153 |
154 | if (prevState.translation === null) {
155 | if (onStart) {
156 | onStart(evt);
157 | }
158 | } else if (prevState.translation !== state.translation) {
159 | if (onMove) {
160 | onMove(evt);
161 | }
162 | }
163 | }
164 | }
165 | });
166 |
167 | useIsomorphicLayoutEffect(() => {
168 | if (disabled) {
169 | if (isTracking) {
170 | dispatch({ type: 'reset' });
171 | }
172 | return;
173 | }
174 |
175 | const elemNode = elemRef.current;
176 |
177 | if (!elemNode) {
178 | return;
179 | }
180 |
181 | const track = (target: EventTarget, point: Point) => {
182 | dispatch({ type: 'track', payload: { target, point } });
183 | };
184 |
185 | const move = (point: Point) => {
186 | dispatch({ type: 'move', payload: { point } });
187 | };
188 |
189 | const end = () => {
190 | dispatch({ type: 'end', payload: null });
191 | };
192 |
193 | if (isTracking) {
194 | if (supportsTouch) {
195 | const onTouchMove = (evt: TouchEvent) => {
196 | if (isMoving && evt.cancelable) {
197 | // evt.preventDefault();
198 | evt.stopImmediatePropagation();
199 | }
200 |
201 | if (evt.touches.length === 1) {
202 | const touchEvent = evt.touches[0];
203 |
204 | move({ x: touchEvent.pageX, y: touchEvent.pageY });
205 | } else {
206 | end();
207 | }
208 | };
209 | const onTouchEnd = (evt: TouchEvent) => {
210 | if (isMoving && evt.cancelable) {
211 | // evt.preventDefault();
212 | evt.stopImmediatePropagation();
213 | }
214 |
215 | end();
216 | };
217 |
218 | const body = document.body;
219 |
220 | const unsubscribeTouchMove = subscribeEvent(
221 | body,
222 | 'touchmove',
223 | onTouchMove
224 | );
225 | const unsubscribeTouchEnd = subscribeEvent(
226 | body,
227 | 'touchend',
228 | onTouchEnd
229 | );
230 | const unsubscribeTouchCancel = subscribeEvent(
231 | body,
232 | 'touchcancel',
233 | onTouchEnd
234 | );
235 |
236 | return () => {
237 | unsubscribeTouchMove();
238 | unsubscribeTouchEnd();
239 | unsubscribeTouchCancel();
240 | };
241 | } else {
242 | const onMouseMove = (evt: MouseEvent) => {
243 | if (isMoving) {
244 | evt.preventDefault();
245 | evt.stopImmediatePropagation();
246 | }
247 |
248 | if (evt.buttons === undefined || evt.buttons === 1) {
249 | move({ x: evt.pageX, y: evt.pageY });
250 | } else {
251 | end();
252 | }
253 | };
254 | const onMouseUp = (evt: MouseEvent) => {
255 | if (isMoving) {
256 | evt.preventDefault();
257 | evt.stopImmediatePropagation();
258 | }
259 |
260 | end();
261 | };
262 |
263 | const body = document.body;
264 |
265 | const unsubscribeMouseMove = subscribeEvent(
266 | body,
267 | 'mousemove',
268 | onMouseMove
269 | );
270 | const unsubscribeMouseUp = subscribeEvent(body, 'mouseup', onMouseUp);
271 |
272 | return () => {
273 | unsubscribeMouseMove();
274 | unsubscribeMouseUp();
275 | };
276 | }
277 | } else {
278 | if (supportsTouch) {
279 | const onTouchStart = (evt: TouchEvent) => {
280 | if (evt.touches.length === 1) {
281 | const touchEvent = evt.touches[0];
282 |
283 | track(touchEvent.target, {
284 | x: touchEvent.pageX,
285 | y: touchEvent.pageY,
286 | });
287 | }
288 | };
289 | const onContextMenu = (evt: MouseEvent) => {
290 | evt.preventDefault();
291 | };
292 |
293 | const unsubscribeTouchStart = subscribeEvent(
294 | elemNode,
295 | 'touchstart',
296 | onTouchStart
297 | );
298 | window.addEventListener('contextmenu', onContextMenu);
299 |
300 | return () => {
301 | unsubscribeTouchStart();
302 | window.removeEventListener('contextmenu', onContextMenu);
303 | };
304 | } else {
305 | const onMouseDown = (evt: MouseEvent) => {
306 | if (
307 | evt.target &&
308 | (evt.buttons === undefined || evt.buttons === 1)
309 | ) {
310 | track(evt.target, { x: evt.pageX, y: evt.pageY });
311 | }
312 | };
313 |
314 | const unsubscribeMouseDown = subscribeEvent(
315 | elemNode,
316 | 'mousedown',
317 | onMouseDown
318 | );
319 |
320 | return () => {
321 | unsubscribeMouseDown();
322 | };
323 | }
324 | }
325 | }, [disabled, isTracking, isMoving]);
326 |
327 | const divStyle = useMemo(() => {
328 | const style: React.CSSProperties = {};
329 |
330 | if (isMoving) {
331 | Object.assign(style, {
332 | touchAction: 'none',
333 | pointerEvents: 'none',
334 | WebkitUserSelect: 'none',
335 | MozUserSelect: 'none',
336 | msUserSelect: 'none',
337 | userSelect: 'none',
338 | });
339 | }
340 |
341 | if (divProps.style) {
342 | Object.assign(style, divProps.style);
343 | }
344 |
345 | return style;
346 | }, [isMoving, divProps.style]);
347 |
348 | divProps.style = divStyle;
349 |
350 | return (
351 |
352 | {render ? render(state) : children}
353 |
354 | );
355 | }
356 | );
357 |
358 | export default Pannable;
359 |
--------------------------------------------------------------------------------
/packages/website/static/img/undraw_docusaurus_tree.svg:
--------------------------------------------------------------------------------
1 | docu_tree
--------------------------------------------------------------------------------
/packages/demo/src/stories/pannable/index.stories.js:
--------------------------------------------------------------------------------
1 | import React, { Fragment, useState, useCallback, useMemo, useRef } from 'react';
2 | import { AutoResizing, Pannable } from 'react-pannable';
3 | import { withKnobs, boolean } from '@storybook/addon-knobs';
4 | import clsx from 'clsx';
5 | import '../../ui/overview.css';
6 | import './pan.css';
7 | import SvgNote from './SvgNote';
8 | import SvgScale from './SvgScale';
9 | import SvgRotate from './SvgRotate';
10 | import SvgPan from './SvgPan';
11 | import SvgSticker from './SvgSticker';
12 |
13 | export default {
14 | title: 'Pannable',
15 | decorators: [withKnobs],
16 | };
17 |
18 | export const DraggableNotes = () => {
19 | const panDisabled = boolean('Drag Disabled', false);
20 | const cancelsOut = boolean('Cancels when Dragged Out the Container', true);
21 |
22 | const [disabled, setDisabled] = useState(false);
23 | const [drag, setDrag] = useState(null);
24 | const [boxSize, setBoxSize] = useState({ width: 400, height: 600 });
25 | const points = {
26 | note0: useState({ x: 20, y: 20 }),
27 | note1: useState({ x: 20, y: 240 }),
28 | };
29 | const pointsRef = useRef();
30 | pointsRef.current = points;
31 |
32 | const onBoxResize = useCallback((size) => {
33 | setBoxSize(size);
34 | }, []);
35 |
36 | const shouldStart = useCallback(
37 | ({ target }) => !!getDraggableKey(target),
38 | []
39 | );
40 |
41 | const onStart = useCallback((evt) => {
42 | const key = getDraggableKey(evt.target);
43 | const startPoint = pointsRef.current[key][0];
44 |
45 | setDrag({ key, startPoint });
46 | console.log('onStart', evt);
47 | }, []);
48 |
49 | const onMove = useCallback(
50 | (evt) => {
51 | if (!drag) {
52 | return;
53 | }
54 |
55 | const setPoint = points[drag.key][1];
56 |
57 | setPoint({
58 | x: drag.startPoint.x + evt.translation.x,
59 | y: drag.startPoint.y + evt.translation.y,
60 | });
61 | },
62 | [drag]
63 | );
64 |
65 | const onEnd = useCallback((evt) => {
66 | setDrag(null);
67 | console.log('onEnd', evt);
68 | }, []);
69 |
70 | const onCancel = useCallback(
71 | (evt) => {
72 | setDrag(null);
73 |
74 | if (drag) {
75 | const setPoint = points[drag.key][1];
76 | const point = drag.startPoint;
77 |
78 | setPoint(point);
79 | }
80 |
81 | setDisabled(false);
82 | console.log('onCancel', evt);
83 | },
84 | [drag]
85 | );
86 |
87 | useMemo(() => {
88 | setDisabled(panDisabled);
89 | }, [panDisabled]);
90 |
91 | const dragPoint = drag ? points[drag.key][0] : null;
92 |
93 | useMemo(() => {
94 | if (cancelsOut && !disabled && dragPoint) {
95 | const maxPoint = {
96 | x: boxSize.width - 200,
97 | y: boxSize.height - 200,
98 | };
99 |
100 | if (
101 | dragPoint.x < 0 ||
102 | dragPoint.y < 0 ||
103 | dragPoint.x > maxPoint.x ||
104 | dragPoint.y > maxPoint.y
105 | ) {
106 | setDisabled(true);
107 | }
108 | }
109 | }, [cancelsOut, disabled, dragPoint, boxSize]);
110 |
111 | return (
112 |
113 |
Pannable
114 |
115 | Pannable component can be panned(dragged) around with the touch/mouse.
116 | You can implement the event handlers for this gesture recognizer with
117 | current translation and velocity.
118 |
119 |
120 |
121 |
132 |
142 |
143 |
144 |
You can drag me.
145 |
155 |
156 |
157 |
166 |
167 |
168 |
169 | Drag here
170 |
171 |
172 | You can only drag me by trigger.
173 |
174 |
175 |
176 |
177 |
178 |
179 |
180 | );
181 | };
182 |
183 | export const AdjustableSticker = () => {
184 | const [transform, setTransform] = useState({
185 | width: 300,
186 | height: 300,
187 | translateX: 20,
188 | translateY: 20,
189 | rotate: 0,
190 | });
191 | const [drag, setDrag] = useState(null);
192 | const [disabled, setDisabled] = useState(false);
193 | const transformRef = useRef();
194 | transformRef.current = transform;
195 |
196 | const onDone = useCallback(() => {
197 | setDisabled(true);
198 | }, []);
199 |
200 | const onEdit = useCallback(() => {
201 | setDisabled(false);
202 | }, []);
203 |
204 | const shouldStart = useCallback(({ target }) => !!getDragAction(target), []);
205 |
206 | const onStart = useCallback(({ target }) => {
207 | const action = getDragAction(target);
208 |
209 | setDrag({ action, startTransform: transformRef.current });
210 | }, []);
211 |
212 | const onMove = useCallback(
213 | ({ translation }) => {
214 | if (!drag) {
215 | return;
216 | }
217 |
218 | const { action, startTransform } = drag;
219 |
220 | if (action === 'translate') {
221 | setTransform((prevTransform) => ({
222 | ...prevTransform,
223 | translateX: startTransform.translateX + translation.x,
224 | translateY: startTransform.translateY + translation.y,
225 | }));
226 | }
227 | if (action === 'scale') {
228 | setTransform((prevTransform) => ({
229 | ...prevTransform,
230 | width: Math.max(100, startTransform.width + translation.x),
231 | height: Math.max(100, startTransform.height + translation.y),
232 | }));
233 | }
234 | if (action === 'rotate') {
235 | setTransform((prevTransform) => ({
236 | ...prevTransform,
237 | rotate: calculateRotate(startTransform, translation),
238 | }));
239 | }
240 | },
241 | [drag]
242 | );
243 |
244 | const onEnd = useCallback(() => {
245 | setDrag(null);
246 | }, []);
247 |
248 | return (
249 |
250 |
Pannable
251 |
252 | Pannable component can be panned(dragged) around with the touch/mouse.
253 | You can implement the event handlers for this gesture recognizer with
254 | current translation and velocity.
255 |
256 |
257 |
270 |
271 | {disabled ? (
272 |
273 | Edit
274 |
275 | ) : (
276 |
277 |
281 |
282 |
283 |
284 | Done
285 |
286 |
287 | )}
288 |
289 |
290 |
291 | );
292 | };
293 |
294 | function getDraggableKey(target) {
295 | if (target.dataset) {
296 | if (target.dataset.draggable) {
297 | return target.dataset.draggable;
298 | }
299 |
300 | if (target.dataset.dragbox) {
301 | return null;
302 | }
303 | }
304 |
305 | if (target.parentNode) {
306 | return getDraggableKey(target.parentNode);
307 | }
308 |
309 | return null;
310 | }
311 |
312 | function getDragAction(target) {
313 | if (target.dataset) {
314 | if (target.dataset.action) {
315 | return target.dataset.action;
316 | }
317 |
318 | if (target.dataset.dragbox) {
319 | return null;
320 | }
321 | }
322 |
323 | if (target.parentNode) {
324 | return getDragAction(target.parentNode);
325 | }
326 |
327 | return null;
328 | }
329 |
330 | function calculateRotate({ rotate, width, height }, { x, y }) {
331 | const sr = 0.5 * Math.sqrt(width * width + height * height);
332 | const sx = -Math.cos(rotate - 0.25 * Math.PI) * sr;
333 | const sy = -Math.sin(rotate - 0.25 * Math.PI) * sr;
334 | const ex = sx + x;
335 | const ey = sy + y;
336 | const er = Math.sqrt(ex * ex + ey * ey);
337 | const redirect = ey >= 0 ? 1 : -1;
338 |
339 | return -redirect * Math.acos(-ex / er) + 0.25 * Math.PI;
340 | }
341 |
342 | function convertTranslate(translate) {
343 | return {
344 | transform: `translate3d(${translate.x}px, ${translate.y}px, 0)`,
345 | WebkitTransform: `translate3d(${translate.x}px, ${translate.y}px, 0)`,
346 | msTransform: `translate(${translate.x}px, ${translate.y}px)`,
347 | };
348 | }
349 |
350 | function convertTransform(transform) {
351 | return {
352 | width: transform.width,
353 | height: transform.height,
354 | transform: `translate3d(${transform.translateX}px, ${transform.translateY}px, 0) rotate(${transform.rotate})`,
355 | WebkitTransform: `translate3d(${transform.translateX}px, ${transform.translateY}px, 0) rotate(${transform.rotate}rad)`,
356 | msTransform: `translate(${transform.translateX}px, ${transform.translateY}px) rotate(${transform.rotate})`,
357 | };
358 | }
359 |
--------------------------------------------------------------------------------