├── 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 |
7 |
{props.children}
8 |
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 | [![Try it on CodePen](https://img.shields.io/badge/CodePen-Run-blue.svg?logo=CodePen)](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 |
44 |
45 |
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 | [![Try it on CodePen](https://img.shields.io/badge/CodePen-Run-blue.svg?logo=CodePen)](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 | [![Try it on CodePen](https://img.shields.io/badge/CodePen-Run-blue.svg?logo=CodePen)](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 | [![Try it on CodePen](https://img.shields.io/badge/CodePen-Run-blue.svg?logo=CodePen)](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 | [![Try it on CodePen](https://img.shields.io/badge/CodePen-Run-blue.svg?logo=CodePen)](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 | [![npm version](https://img.shields.io/npm/v/react-pannable.svg)](https://www.npmjs.com/package/react-pannable) 6 | ![npm license](https://img.shields.io/npm/l/react-pannable.svg) 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 | [![npm version](https://img.shields.io/npm/v/react-pannable.svg)](https://www.npmjs.com/package/react-pannable) 6 | ![npm license](https://img.shields.io/npm/l/react-pannable.svg) 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 | [![Try it on CodePen](https://img.shields.io/badge/CodePen-Run-blue.svg?logo=CodePen)](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 | [![Try it on CodePen](https://img.shields.io/badge/CodePen-Run-blue.svg?logo=CodePen)](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 |
127 | And you can{' '} 128 | 129 | open the link 130 | 131 | . 132 |
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 |
146 | And you can{' '} 147 | 151 | open the link 152 | 153 | . 154 |
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 | --------------------------------------------------------------------------------