├── .firebaserc
├── .gitignore
├── .vscode
└── settings.json
├── README.md
├── firebase.json
├── gifs
├── MenuComponent.gif
├── SideDrawer.gif
└── highlight.gif
├── package.json
├── public
├── favicon.ico
├── index.html
├── logo192.png
├── logo512.png
├── manifest.json
└── robots.txt
├── src
├── App.test.tsx
├── App.tsx
├── assets
│ └── icons
│ │ └── essentials
│ │ ├── generateIndex.js
│ │ ├── index.ts
│ │ ├── svg
│ │ ├── add.svg
│ │ ├── at-circle.svg
│ │ ├── bag.svg
│ │ ├── bar-chart.svg
│ │ ├── bookmarks.svg
│ │ ├── chatbox-ellipses.svg
│ │ ├── checkmark.svg
│ │ ├── chevron-back.svg
│ │ ├── chevron-forward.svg
│ │ ├── close.svg
│ │ ├── cloud-upload.svg
│ │ ├── cloud.svg
│ │ ├── color-fill.svg
│ │ ├── color-palette.svg
│ │ ├── color-wand.svg
│ │ ├── dice.svg
│ │ ├── document.svg
│ │ ├── download.svg
│ │ ├── ellipsis-horizontal.svg
│ │ ├── ellipsis-vertical.svg
│ │ ├── expand.svg
│ │ ├── game-controller.svg
│ │ ├── heart.svg
│ │ ├── home.svg
│ │ ├── link.svg
│ │ ├── list.svg
│ │ ├── log-out.svg
│ │ ├── remove.svg
│ │ ├── ribbon.svg
│ │ ├── search.svg
│ │ ├── settings.svg
│ │ ├── share.svg
│ │ ├── snow.svg
│ │ ├── storefront.svg
│ │ ├── tennisball.svg
│ │ ├── text.svg
│ │ ├── time.svg
│ │ └── trophy.svg
│ │ └── withStyle.tsx
├── components
│ ├── Buttons
│ │ ├── Button.tsx
│ │ ├── IconButton.tsx
│ │ └── index.ts
│ ├── Hightlight
│ │ ├── ColorGrid.tsx
│ │ ├── Notes.tsx
│ │ ├── StyleEditor.tsx
│ │ ├── TextStyleGrid.tsx
│ │ ├── index.tsx
│ │ ├── styles.ts
│ │ ├── types.ts
│ │ └── utils.ts
│ ├── Menu
│ │ ├── Container.tsx
│ │ ├── Controls.tsx
│ │ ├── Items.tsx
│ │ ├── Menu.tsx
│ │ ├── index.ts
│ │ └── types.ts
│ ├── SlideDrawer
│ │ ├── Backdrop.tsx
│ │ ├── SideDrawer.tsx
│ │ ├── TogglerButton.tsx
│ │ ├── content
│ │ │ ├── Navs
│ │ │ │ ├── Navitem.tsx
│ │ │ │ ├── NavitemsGroup.tsx
│ │ │ │ ├── Navs.tsx
│ │ │ │ ├── index.ts
│ │ │ │ ├── sample_navs.tsx
│ │ │ │ └── types.ts
│ │ │ ├── Profile
│ │ │ │ ├── Profile.tsx
│ │ │ │ ├── index.ts
│ │ │ │ └── sample_pic.jpg
│ │ │ ├── QuickSettings.tsx
│ │ │ └── SDContent.tsx
│ │ ├── index.ts
│ │ └── useSideDrawer.ts
│ ├── Switch
│ │ ├── Switch.tsx
│ │ └── index.ts
│ └── Tabs
│ │ ├── index.tsx
│ │ └── types.ts
├── configs
│ └── themes.ts
├── containers
│ ├── Home
│ │ ├── Home.tsx
│ │ ├── SampleHtml
│ │ │ ├── base.css
│ │ │ ├── default.css
│ │ │ ├── index.tsx
│ │ │ └── sample_html.ts
│ │ └── sample_items.tsx
│ └── TestComponent
│ │ └── TestComponent.tsx
├── hooks
│ ├── index.ts
│ ├── useDimensions.ts
│ ├── useHighlightRange.ts
│ ├── usePosition.ts
│ ├── useWindowResize.ts
│ └── utils.ts
├── index.css
├── index.tsx
├── libs
│ ├── highlight-range
│ │ └── index.ts
│ ├── pan
│ │ ├── index.ts
│ │ ├── pan.ts
│ │ └── types.ts
│ └── spring
│ │ ├── index.ts
│ │ ├── spring.ts
│ │ └── types.ts
├── react-app-env.d.ts
├── reportWebVitals.ts
├── setupTests.ts
└── types
│ ├── css.d.ts
│ └── react-style-object-to-css.d.ts
├── tsconfig.json
└── yarn.lock
/.firebaserc:
--------------------------------------------------------------------------------
1 | {
2 | "projects": {
3 | "default": "react-components-by-ruvkr"
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/.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 |
25 | **/.directory
26 | /.firebase
27 | /.eslintcache
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "typescript.tsdk": "node_modules/typescript/lib"
3 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # A collection of Responsive Animated Mobile friendly Lightweight React Components
2 |
3 | - **100% Functional components**
4 | - **Modular**
5 |
6 | ## [👉 Live Demo 🌏](https://react-components-by-ruvkr.web.app/)
7 |
8 | ## Index
9 |
10 | - [Menu](#menu)
11 | - [Side Drawer](#side-drawer)
12 | - [Text Highlight](#text-highlight)
13 |
14 | ## Menu
15 |
16 | 
17 |
18 | - **Multi-level**
19 | - **Auto position**
20 | - **Auto-hide when clicked outside**
21 | - **Uses React portal**
22 |
23 | ## Side Drawer
24 |
25 | 
26 |
27 | - **Swipeable**
28 | - **Responsive**
29 | - **Spring animated**
30 |
31 | ## Text Highlight
32 |
33 | 
34 |
35 | - **Highlight any range**
36 | - **Customize styles**
37 | - **Add note**
38 |
39 |
--------------------------------------------------------------------------------
/firebase.json:
--------------------------------------------------------------------------------
1 | {
2 | "hosting": {
3 | "public": "build",
4 | "ignore": [
5 | "firebase.json",
6 | "**/.*",
7 | "**/node_modules/**"
8 | ],
9 | "rewrites": [
10 | {
11 | "source": "**",
12 | "destination": "/index.html"
13 | }
14 | ]
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/gifs/MenuComponent.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ruvkr/react-components-by-ruvkr/d8c00250eea7feb988619ab1c6f7ddd6927448ac/gifs/MenuComponent.gif
--------------------------------------------------------------------------------
/gifs/SideDrawer.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ruvkr/react-components-by-ruvkr/d8c00250eea7feb988619ab1c6f7ddd6927448ac/gifs/SideDrawer.gif
--------------------------------------------------------------------------------
/gifs/highlight.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ruvkr/react-components-by-ruvkr/d8c00250eea7feb988619ab1c6f7ddd6927448ac/gifs/highlight.gif
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-components-by-ruvkr",
3 | "version": "0.1.0",
4 | "private": false,
5 | "description": "",
6 | "author": {
7 | "name": "ruvkr",
8 | "email": "34903858+ruvkr@users.noreply.github.com"
9 | },
10 | "repository": {
11 | "type": "git",
12 | "url": "git@github.com:ruvkr/react-components-by-ruvkr.git"
13 | },
14 | "dependencies": {
15 | "@testing-library/jest-dom": "^5.11.4",
16 | "@testing-library/react": "^11.1.0",
17 | "@testing-library/user-event": "^12.1.10",
18 | "@types/jest": "^26.0.15",
19 | "@types/node": "^14.14.10",
20 | "@types/react": "^17.0.0",
21 | "@types/react-dom": "^17.0.0",
22 | "@types/styled-components": "^5.1.4",
23 | "@types/uuid": "^8.3.0",
24 | "csstype": "^3.0.6",
25 | "framer-motion": "^2.9.4",
26 | "polished": "^4.0.3",
27 | "react": "^17.0.1",
28 | "react-dom": "^17.0.1",
29 | "react-scripts": "4.0.1",
30 | "react-style-object-to-css": "^1.1.2",
31 | "style-to-js": "^1.1.0",
32 | "styled-components": "^5.2.1",
33 | "typescript": "^4.0.3",
34 | "uuid": "^8.3.1",
35 | "web-vitals": "^1.0.1"
36 | },
37 | "scripts": {
38 | "start": "BROWSER=none react-scripts start",
39 | "build": "GENERATE_SOURCEMAP=false react-scripts build",
40 | "test": "react-scripts test",
41 | "eject": "react-scripts eject"
42 | },
43 | "eslintConfig": {
44 | "extends": [
45 | "react-app",
46 | "react-app/jest"
47 | ]
48 | },
49 | "browserslist": {
50 | "production": [
51 | ">0.2%",
52 | "not dead",
53 | "not op_mini all"
54 | ],
55 | "development": [
56 | "last 1 chrome version",
57 | "last 1 firefox version",
58 | "last 1 safari version"
59 | ]
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ruvkr/react-components-by-ruvkr/d8c00250eea7feb988619ab1c6f7ddd6927448ac/public/favicon.ico
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
17 |
18 |
27 | React App
28 |
29 |
30 |
31 |
32 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ruvkr/react-components-by-ruvkr/d8c00250eea7feb988619ab1c6f7ddd6927448ac/public/logo192.png
--------------------------------------------------------------------------------
/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ruvkr/react-components-by-ruvkr/d8c00250eea7feb988619ab1c6f7ddd6927448ac/public/logo512.png
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "logo512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#000000",
24 | "background_color": "#ffffff"
25 | }
26 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/src/App.test.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, screen } from '@testing-library/react';
3 | import App from './App';
4 |
5 | test('renders learn react link', () => {
6 | render();
7 | const linkElement = screen.getByText(/learn react/i);
8 | expect(linkElement).toBeInTheDocument();
9 | });
10 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { useRef } from 'react';
2 | import styled from 'styled-components';
3 | import { Home } from './containers/Home/Home';
4 | import { SideDrawer } from './components/SlideDrawer';
5 |
6 | export const App: React.FC = () => {
7 | const appRef = useRef(null);
8 |
9 | return (
10 |
11 |
12 |
13 |
14 | );
15 | };
16 |
17 | const ScApp = styled.div`
18 | position: fixed;
19 | width: 100%;
20 | height: 100%;
21 | overflow: hidden;
22 | top: 0;
23 | right: 0;
24 | bottom: 0;
25 | left: 0;
26 | `;
27 |
--------------------------------------------------------------------------------
/src/assets/icons/essentials/generateIndex.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 | const util = require('util');
3 | const path = require('path');
4 |
5 | const readDir = util.promisify(fs.readdir);
6 | const writeFile = util.promisify(fs.writeFile);
7 |
8 | const getSvgsInDir = dirPath => {
9 | return readDir(dirPath)
10 | .then(files => files.filter(file => /\.svg$/i.test(file)))
11 | .catch(console.log);
12 | };
13 |
14 | const getNames = files => {
15 | return files.map(file => {
16 | const fileName = file
17 | .replace(/\.svg$/i, '')
18 | .replace(/[^A-Z0-9]/gi, ' ')
19 | .replace(/(^\w|\s\w)/g, m => m.toUpperCase())
20 | .replace(/\s/g, '');
21 | return { import: fileName + 'Svg', export: fileName, file: file };
22 | });
23 | };
24 |
25 | const getModules = fileNames => {
26 | let importsString =
27 | "// This file is auto generated by generateIndex.js\n\nimport { withStyle } from './withStyle';\n\n";
28 | let exportsString = '\n';
29 |
30 | fileNames.forEach(name => {
31 | importsString += `import { ReactComponent as ${name.import} } from './svg/${name.file}';\n`;
32 | exportsString += `export const ${name.export} = withStyle(${name.import});\n`;
33 | });
34 |
35 | return importsString + exportsString;
36 | };
37 |
38 | const writeIndex = (path, strings) => {
39 | writeFile(path, strings, { encoding: 'utf-8' })
40 | .then(_ => console.log('index.ts created!'))
41 | .catch(console.log);
42 | };
43 |
44 | (async _ => {
45 | const svgsPath = path.join(__dirname, 'svg');
46 | const indexPath = path.join(__dirname, 'index.ts');
47 | const svgs = await getSvgsInDir(svgsPath);
48 | const fileNames = getNames(svgs);
49 | const strings = getModules(fileNames);
50 | await writeIndex(indexPath, strings);
51 | })();
52 |
--------------------------------------------------------------------------------
/src/assets/icons/essentials/index.ts:
--------------------------------------------------------------------------------
1 | // This file is auto generated by generateIndex.js
2 |
3 | import { withStyle } from './withStyle';
4 |
5 | import { ReactComponent as AddSvg } from './svg/add.svg';
6 | import { ReactComponent as AtCircleSvg } from './svg/at-circle.svg';
7 | import { ReactComponent as BagSvg } from './svg/bag.svg';
8 | import { ReactComponent as BarChartSvg } from './svg/bar-chart.svg';
9 | import { ReactComponent as BookmarksSvg } from './svg/bookmarks.svg';
10 | import { ReactComponent as ChatboxEllipsesSvg } from './svg/chatbox-ellipses.svg';
11 | import { ReactComponent as CheckmarkSvg } from './svg/checkmark.svg';
12 | import { ReactComponent as ChevronBackSvg } from './svg/chevron-back.svg';
13 | import { ReactComponent as ChevronForwardSvg } from './svg/chevron-forward.svg';
14 | import { ReactComponent as CloseSvg } from './svg/close.svg';
15 | import { ReactComponent as CloudUploadSvg } from './svg/cloud-upload.svg';
16 | import { ReactComponent as CloudSvg } from './svg/cloud.svg';
17 | import { ReactComponent as ColorFillSvg } from './svg/color-fill.svg';
18 | import { ReactComponent as ColorPaletteSvg } from './svg/color-palette.svg';
19 | import { ReactComponent as ColorWandSvg } from './svg/color-wand.svg';
20 | import { ReactComponent as DiceSvg } from './svg/dice.svg';
21 | import { ReactComponent as DocumentSvg } from './svg/document.svg';
22 | import { ReactComponent as DownloadSvg } from './svg/download.svg';
23 | import { ReactComponent as EllipsisHorizontalSvg } from './svg/ellipsis-horizontal.svg';
24 | import { ReactComponent as EllipsisVerticalSvg } from './svg/ellipsis-vertical.svg';
25 | import { ReactComponent as ExpandSvg } from './svg/expand.svg';
26 | import { ReactComponent as GameControllerSvg } from './svg/game-controller.svg';
27 | import { ReactComponent as HeartSvg } from './svg/heart.svg';
28 | import { ReactComponent as HomeSvg } from './svg/home.svg';
29 | import { ReactComponent as LinkSvg } from './svg/link.svg';
30 | import { ReactComponent as ListSvg } from './svg/list.svg';
31 | import { ReactComponent as LogOutSvg } from './svg/log-out.svg';
32 | import { ReactComponent as RemoveSvg } from './svg/remove.svg';
33 | import { ReactComponent as RibbonSvg } from './svg/ribbon.svg';
34 | import { ReactComponent as SearchSvg } from './svg/search.svg';
35 | import { ReactComponent as SettingsSvg } from './svg/settings.svg';
36 | import { ReactComponent as ShareSvg } from './svg/share.svg';
37 | import { ReactComponent as SnowSvg } from './svg/snow.svg';
38 | import { ReactComponent as StorefrontSvg } from './svg/storefront.svg';
39 | import { ReactComponent as TennisballSvg } from './svg/tennisball.svg';
40 | import { ReactComponent as TextSvg } from './svg/text.svg';
41 | import { ReactComponent as TimeSvg } from './svg/time.svg';
42 | import { ReactComponent as TrophySvg } from './svg/trophy.svg';
43 |
44 | export const Add = withStyle(AddSvg);
45 | export const AtCircle = withStyle(AtCircleSvg);
46 | export const Bag = withStyle(BagSvg);
47 | export const BarChart = withStyle(BarChartSvg);
48 | export const Bookmarks = withStyle(BookmarksSvg);
49 | export const ChatboxEllipses = withStyle(ChatboxEllipsesSvg);
50 | export const Checkmark = withStyle(CheckmarkSvg);
51 | export const ChevronBack = withStyle(ChevronBackSvg);
52 | export const ChevronForward = withStyle(ChevronForwardSvg);
53 | export const Close = withStyle(CloseSvg);
54 | export const CloudUpload = withStyle(CloudUploadSvg);
55 | export const Cloud = withStyle(CloudSvg);
56 | export const ColorFill = withStyle(ColorFillSvg);
57 | export const ColorPalette = withStyle(ColorPaletteSvg);
58 | export const ColorWand = withStyle(ColorWandSvg);
59 | export const Dice = withStyle(DiceSvg);
60 | export const Document = withStyle(DocumentSvg);
61 | export const Download = withStyle(DownloadSvg);
62 | export const EllipsisHorizontal = withStyle(EllipsisHorizontalSvg);
63 | export const EllipsisVertical = withStyle(EllipsisVerticalSvg);
64 | export const Expand = withStyle(ExpandSvg);
65 | export const GameController = withStyle(GameControllerSvg);
66 | export const Heart = withStyle(HeartSvg);
67 | export const Home = withStyle(HomeSvg);
68 | export const Link = withStyle(LinkSvg);
69 | export const List = withStyle(ListSvg);
70 | export const LogOut = withStyle(LogOutSvg);
71 | export const Remove = withStyle(RemoveSvg);
72 | export const Ribbon = withStyle(RibbonSvg);
73 | export const Search = withStyle(SearchSvg);
74 | export const Settings = withStyle(SettingsSvg);
75 | export const Share = withStyle(ShareSvg);
76 | export const Snow = withStyle(SnowSvg);
77 | export const Storefront = withStyle(StorefrontSvg);
78 | export const Tennisball = withStyle(TennisballSvg);
79 | export const Text = withStyle(TextSvg);
80 | export const Time = withStyle(TimeSvg);
81 | export const Trophy = withStyle(TrophySvg);
82 |
--------------------------------------------------------------------------------
/src/assets/icons/essentials/svg/add.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/icons/essentials/svg/at-circle.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/icons/essentials/svg/bag.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/icons/essentials/svg/bar-chart.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/icons/essentials/svg/bookmarks.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/icons/essentials/svg/chatbox-ellipses.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/icons/essentials/svg/checkmark.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/icons/essentials/svg/chevron-back.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/icons/essentials/svg/chevron-forward.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/icons/essentials/svg/close.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/icons/essentials/svg/cloud-upload.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/icons/essentials/svg/cloud.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/icons/essentials/svg/color-fill.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/icons/essentials/svg/color-palette.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/icons/essentials/svg/color-wand.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/icons/essentials/svg/dice.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/icons/essentials/svg/document.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/icons/essentials/svg/download.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/icons/essentials/svg/ellipsis-horizontal.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/icons/essentials/svg/ellipsis-vertical.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/icons/essentials/svg/expand.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/icons/essentials/svg/game-controller.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/icons/essentials/svg/heart.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/icons/essentials/svg/home.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/icons/essentials/svg/link.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/icons/essentials/svg/list.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/icons/essentials/svg/log-out.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/icons/essentials/svg/remove.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/icons/essentials/svg/ribbon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/icons/essentials/svg/search.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/icons/essentials/svg/settings.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/icons/essentials/svg/share.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/icons/essentials/svg/snow.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/icons/essentials/svg/storefront.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/icons/essentials/svg/tennisball.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/icons/essentials/svg/text.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/icons/essentials/svg/time.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/icons/essentials/svg/trophy.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/icons/essentials/withStyle.tsx:
--------------------------------------------------------------------------------
1 | export const withStyle = (
2 | SVG: React.FC>,
3 | ): React.FC => {
4 | return props => {
5 | return (
6 |
13 | );
14 | };
15 | };
16 |
17 | export default withStyle;
18 |
--------------------------------------------------------------------------------
/src/components/Buttons/Button.tsx:
--------------------------------------------------------------------------------
1 | import { rgba } from 'polished';
2 | import styled from 'styled-components';
3 |
4 | interface Props {
5 | disabled?: boolean;
6 | icon?: JSX.Element;
7 | name?: string;
8 | className?: string;
9 | onClick?: (event: React.MouseEvent) => void;
10 | }
11 |
12 | export const Button: React.FC = ({
13 | disabled = false,
14 | icon,
15 | name,
16 | className,
17 | onClick,
18 | }) => {
19 | return (
20 |
21 |
22 | {icon && {icon}}
23 | {name && {name}}
24 |
25 |
26 | );
27 | };
28 |
29 | const ScButton = styled.button`
30 | font: inherit;
31 | padding: 0;
32 | margin: 0;
33 | border: none;
34 | display: flex;
35 | background-color: transparent;
36 | cursor: pointer;
37 | transition: opacity 300ms ease-in-out;
38 |
39 | &:focus,
40 | &:active {
41 | outline: none;
42 | & > div {
43 | box-shadow: 0 0 0 2px ${p => p.theme.col3};
44 | transition: box-shadow 300ms ease-in-out;
45 | }
46 | }
47 | &:disabled {
48 | cursor: not-allowed;
49 | opacity: 0.2;
50 | transition: opacity 300ms ease-in-out;
51 | }
52 |
53 | @media (hover: hover) and (pointer: fine) {
54 | &:hover:not(:disabled) {
55 | & > div {
56 | box-shadow: 0 0 0 2px ${p => p.theme.col3};
57 | transition: box-shadow 300ms ease-in-out;
58 | }
59 | }
60 | }
61 | `;
62 |
63 | const ScFocus = styled.div`
64 | width: 100%;
65 | height: 100%;
66 | min-height: 36px;
67 | display: flex;
68 | align-items: center;
69 | border-radius: 999px;
70 | background-color: ${p => p.theme.col4};
71 | overflow: hidden;
72 | transition: box-shadow 300ms ease-in-out;
73 |
74 | &:focus,
75 | &:active {
76 | outline: none;
77 | }
78 | `;
79 |
80 | const ScIcon = styled.div`
81 | display: flex;
82 | flex-shrink: 0;
83 | width: 36px;
84 | height: 36px;
85 | padding: 8px;
86 | color: ${p => p.theme.col2};
87 | fill: ${p => p.theme.col2};
88 | stroke: ${p => p.theme.col2};
89 | background-color: rgba(0, 0, 0, 0.15);
90 | `;
91 |
92 | const ScLabel = styled.div<{ $hasIcon: boolean }>`
93 | color: ${p => rgba(p.theme.col1, 0.5)};
94 | padding: 8px 16px;
95 | padding-left: ${p => (p.$hasIcon ? 8 : 16)}px;
96 | `;
97 |
--------------------------------------------------------------------------------
/src/components/Buttons/IconButton.tsx:
--------------------------------------------------------------------------------
1 | import styled, { css } from 'styled-components';
2 |
3 | interface Props {
4 | disabled?: boolean;
5 | active?: boolean;
6 | icon: JSX.Element;
7 | className?: string;
8 | onClick?: (event: React.MouseEvent) => void;
9 | }
10 |
11 | export const IconButton: React.FC = ({
12 | disabled = false,
13 | active = false,
14 | icon,
15 | className,
16 | onClick,
17 | }) => {
18 | return (
19 |
20 |
21 | {icon}
22 |
23 |
24 | );
25 | };
26 |
27 | const ScButton = styled.button`
28 | font: inherit;
29 | padding: 0;
30 | margin: 0;
31 | border: none;
32 | display: flex;
33 | background-color: transparent;
34 | cursor: pointer;
35 | transition: opacity 300ms ease-in-out;
36 |
37 | &:focus,
38 | &:active {
39 | outline: none;
40 | & > div {
41 | box-shadow: 0 0 0 2px ${p => p.theme.col3};
42 | transition: box-shadow 300ms ease-in-out;
43 | }
44 | }
45 | &:disabled {
46 | cursor: not-allowed;
47 | opacity: 0.2;
48 | transition: opacity 300ms ease-in-out;
49 | }
50 |
51 | @media (hover: hover) and (pointer: fine) {
52 | &:hover:not(:disabled) {
53 | & > div {
54 | box-shadow: 0 0 0 2px ${p => p.theme.col3};
55 | transition: box-shadow 300ms ease-in-out;
56 | }
57 | }
58 | }
59 | `;
60 |
61 | const ScFocus = styled.div<{ $active: boolean }>(
62 | ({ $active, theme }) => css`
63 | width: 36px;
64 | height: 36px;
65 | border-radius: 50%;
66 | overflow: hidden;
67 | background-color: ${$active ? theme.col2 : theme.col4};
68 | transition: all 300ms ease-in-out;
69 |
70 | &:focus,
71 | &:active {
72 | outline: none;
73 | }
74 | `
75 | );
76 |
77 | const ScIcon = styled.div<{ $active: boolean }>(
78 | ({ $active, theme }) => css`
79 | display: flex;
80 | flex-shrink: 0;
81 | width: 36px;
82 | height: 36px;
83 | padding: 8px;
84 | color: ${$active ? theme.col4 : theme.col2};
85 | fill: ${$active ? theme.col4 : theme.col2};
86 | stroke: ${$active ? theme.col4 : theme.col2};
87 | transition: all 300ms ease-in-out;
88 | `
89 | );
90 |
--------------------------------------------------------------------------------
/src/components/Buttons/index.ts:
--------------------------------------------------------------------------------
1 | export * from './Button';
2 | export * from './IconButton';
3 |
--------------------------------------------------------------------------------
/src/components/Hightlight/ColorGrid.tsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import { rgba } from 'polished';
3 | import { Close } from '../../assets/icons/essentials';
4 |
5 | interface Props {
6 | name: string;
7 | colors: string[];
8 | activeColor?: string;
9 | onChange?: (color: string) => void;
10 | }
11 |
12 | export const ColorGrid: React.FC = ({
13 | name,
14 | colors,
15 | activeColor,
16 | onChange,
17 | }) => {
18 | const _colors = colors.map(color => (
19 | onChange && onChange(color)}>
20 |
21 |
22 | ));
23 |
24 | return (
25 |
26 |
27 |
28 | onChange && onChange('inherit')}
31 | children={
32 | }
36 | />
37 | }
38 | />
39 | {_colors}
40 |
41 |
42 | );
43 | };
44 |
45 | const ScContainer = styled.div`
46 | display: grid;
47 | grid-auto-flow: row;
48 | grid-auto-rows: min-content;
49 | grid-gap: 8px;
50 | `;
51 |
52 | const ScLabel = styled.label`
53 | width: 100%;
54 | padding: 0;
55 | margin: 0;
56 | color: ${p => rgba(p.theme.col1, 0.5)};
57 | `;
58 |
59 | const ScColorGrid = styled.div`
60 | width: 100%;
61 | display: flex;
62 | flex-wrap: wrap;
63 | `;
64 |
65 | const ScColorItem = styled.button`
66 | font: inherit;
67 | padding: 0;
68 | margin: 2px;
69 | border: none;
70 | background-color: transparent;
71 | cursor: pointer;
72 |
73 | &:focus,
74 | &:active {
75 | outline: none;
76 | & > div {
77 | transform: scale(1.1);
78 | transition: transform 300ms ease-in-out;
79 | }
80 | }
81 |
82 | @media (hover: hover) and (pointer: fine) {
83 | &:hover:not(:disabled) {
84 | & > div {
85 | transform: scale(1.1);
86 | transition: transform 300ms ease-in-out;
87 | }
88 | }
89 | }
90 | `;
91 |
92 | const ScFocus = styled.div<{ $color: string; $active: boolean }>`
93 | width: 28px;
94 | height: 28px;
95 | background-color: ${p => p.$color};
96 | border-radius: ${p => (p.$active ? 50 : 0)}%;
97 | transition: transform 300ms ease-in-out;
98 |
99 | &:focus,
100 | &:active {
101 | outline: none;
102 | }
103 | `;
104 |
105 | const ScNoneFocus = styled.div<{ $active: boolean }>`
106 | width: 28px;
107 | height: 28px;
108 | padding: 6px;
109 | background-color: ${p => p.theme.col4};
110 | border-radius: ${p => (p.$active ? 50 : 0)}%;
111 | transition: transform 300ms ease-in-out;
112 |
113 | &:focus,
114 | &:active {
115 | outline: none;
116 | }
117 | `;
118 |
119 | const ScNoneIcon = styled(Close)`
120 | width: 100%;
121 | height: 100%;
122 | display: flex;
123 | color: ${p => p.theme.col2};
124 | fill: ${p => p.theme.col2};
125 | stroke: ${p => p.theme.col2};
126 | `;
127 |
--------------------------------------------------------------------------------
/src/components/Hightlight/Notes.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import styled, { css } from 'styled-components';
3 | import { rgba } from 'polished';
4 | import { Button } from '../Buttons';
5 | import { Checkmark, Remove } from '../../assets/icons/essentials';
6 |
7 | interface Props {
8 | note: string | null;
9 | updateNote: (note: string) => void;
10 | }
11 |
12 | export const Notes: React.FC = ({ note, updateNote }) => {
13 | const [value, setValue] = useState(note ?? '');
14 |
15 | const changeHandler = (event: React.ChangeEvent) => {
16 | const target = event.target as HTMLTextAreaElement;
17 | target.style.height = '';
18 | target.style.height = Math.min(target.scrollHeight, 240) + 'px';
19 | const value = target.value;
20 | setValue(value);
21 | };
22 |
23 | const removeHandler = () => {
24 | setValue('');
25 | updateNote('');
26 | };
27 |
28 | return (
29 |
30 |
36 |
37 | }
39 | name='Save'
40 | onClick={() => updateNote(value)}
41 | />
42 | } name='Delete' onClick={removeHandler} />
43 |
44 |
45 |
46 | );
47 | };
48 |
49 | const ScContainer = styled.div`
50 | display: grid;
51 | grid-gap: 8px;
52 | padding: 8px;
53 | `;
54 |
55 | const ScInfo = styled.label`
56 | font-size: 14px;
57 | text-align: right;
58 | color: ${p => p.theme.col3};
59 | `;
60 |
61 | const ScControls = styled.div`
62 | display: grid;
63 | grid-template-columns: auto auto 1fr;
64 | align-items: center;
65 | grid-gap: 8px;
66 | `;
67 |
68 | const ScTextarea = styled.textarea(
69 | ({ theme }) => css`
70 | font: inherit;
71 | border: none;
72 | border-radius: 4px;
73 | padding: 0.5em;
74 | margin: 0;
75 | background-color: transparent;
76 | color: ${rgba(theme.col1, 0.5)};
77 | resize: none;
78 | transition: all 300ms ease-in-out;
79 |
80 | &:hover:not(:disabled),
81 | &:focus {
82 | outline: none;
83 | box-shadow: 0 0 0 2px ${theme.col3};
84 | transition: all 300ms ease-in-out;
85 | }
86 | &:disabled {
87 | opacity: 0.2;
88 | cursor: not-allowed;
89 | }
90 | &::selection {
91 | background-color: ${theme.col2};
92 | }
93 | ::placeholder {
94 | color: ${p => p.theme.col3};
95 | }
96 | `
97 | );
98 |
--------------------------------------------------------------------------------
/src/components/Hightlight/StyleEditor.tsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import * as CSS from 'csstype';
3 | import { colors, textStyles } from './styles';
4 | import { ColorGrid } from './ColorGrid';
5 | import { TextStyleGrid } from './TextStyleGrid';
6 | import { isin } from './utils';
7 |
8 | interface Props {
9 | allStyles: CSS.Properties;
10 | onChange: (style: CSS.Properties) => void;
11 | }
12 |
13 | export const StyleEditor: React.FC = ({ allStyles, onChange }) => {
14 | const textStyleChangeHandler = (style: CSS.Properties) => {
15 | if (isin(style, allStyles)) {
16 | const newStyles = { ...allStyles };
17 | Object.keys(style).forEach(k => k in newStyles && delete newStyles[k]);
18 | onChange(newStyles);
19 | } else onChange({ ...allStyles, ...style });
20 | };
21 |
22 | const colorChangeHandler = (key: keyof CSS.Properties) => (
23 | value: string
24 | ) => onChange({ ...allStyles, [key]: value });
25 |
26 | return (
27 |
28 |
33 |
39 |
45 |
51 |
52 | );
53 | };
54 |
55 | const ScContainer = styled.div`
56 | font-size: 14px;
57 | display: grid;
58 | grid-template-columns: 1fr 1fr;
59 | grid-gap: 8px;
60 | padding: 8px;
61 | `;
62 |
--------------------------------------------------------------------------------
/src/components/Hightlight/TextStyleGrid.tsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import { rgba } from 'polished';
3 | import * as CSS from 'csstype';
4 | import { TextStyle } from './types';
5 | import { isin } from './utils';
6 |
7 | interface Props {
8 | textStyleItems: TextStyle[];
9 | onChange?: (style: CSS.Properties) => void;
10 | allStyles: CSS.Properties;
11 | }
12 |
13 | export const TextStyleGrid: React.FC = ({
14 | allStyles,
15 | textStyleItems,
16 | onChange,
17 | }) => {
18 | const _styles = textStyleItems.map(item => (
19 | onChange && onChange(item.style)}>
20 |
26 |
27 | ));
28 |
29 | return (
30 |
31 |
32 |
33 |
34 | );
35 | };
36 |
37 | const ScContainer = styled.div`
38 | display: grid;
39 | grid-auto-flow: row;
40 | grid-auto-rows: min-content;
41 | grid-gap: 8px;
42 | `;
43 |
44 | const ScLabel = styled.label`
45 | width: 100%;
46 | padding: 0;
47 | margin: 0;
48 | color: ${p => rgba(p.theme.col1, 0.5)};
49 | `;
50 |
51 | const ScTextStylesGrid = styled.div`
52 | display: flex;
53 | flex-wrap: wrap;
54 | `;
55 |
56 | const ScTextStyle = styled.button`
57 | font: inherit;
58 | padding: 0;
59 | margin: 0;
60 | border: none;
61 | background-color: transparent;
62 | cursor: pointer;
63 |
64 | &:focus,
65 | &:active {
66 | outline: none;
67 | & > div {
68 | color: ${p => p.theme.col2};
69 | transition: color 300ms ease-in-out;
70 | }
71 | }
72 |
73 | @media (hover: hover) and (pointer: fine) {
74 | &:hover:not(:disabled) {
75 | & > div {
76 | color: ${p => p.theme.col2};
77 | transition: color 300ms ease-in-out;
78 | }
79 | }
80 | }
81 | `;
82 |
83 | const ScFocus = styled.div<{ $active: boolean }>`
84 | font-size: 18px;
85 | padding: 8px;
86 | color: ${p => (p.$active ? p.theme.col2 : p.theme.col3)};
87 | transition: color 300ms ease-in-out;
88 |
89 | &:focus,
90 | &:active {
91 | outline: none;
92 | }
93 | `;
94 |
--------------------------------------------------------------------------------
/src/components/Hightlight/index.tsx:
--------------------------------------------------------------------------------
1 | import { useReducer, useEffect } from 'react';
2 | import * as CSS from 'csstype';
3 | import { IconButton } from '../Buttons';
4 | import { Tabs, TabItem } from '../Tabs';
5 | import { useHighlightRange } from '../../hooks';
6 | import {
7 | ColorWand,
8 | Close,
9 | ColorFill,
10 | ChatboxEllipses,
11 | } from '../../assets/icons/essentials';
12 | import { StyleEditor } from './StyleEditor';
13 | import { Notes } from './Notes';
14 |
15 | interface Props {
16 | readerRef: React.MutableRefObject;
17 | className?: string;
18 | size?: number;
19 | onHighlight?: () => void;
20 | }
21 |
22 | interface State {
23 | selecting: boolean;
24 | selectedId: string | null;
25 | selectedStyles: CSS.Properties | null;
26 | selectedNote: string | null;
27 | tagName: string;
28 | attributes: { [key: string]: string };
29 | styles: CSS.Properties;
30 | }
31 |
32 | const initialState: State = {
33 | selecting: false,
34 | selectedId: null,
35 | selectedStyles: null,
36 | selectedNote: null,
37 | tagName: 'span',
38 | attributes: { class: 'highlighted' },
39 | styles: { backgroundColor: 'rgb(240, 240, 48)', color: 'rgb(34, 40, 49)' },
40 | };
41 |
42 | export const Hightlight: React.FC = ({ readerRef, onHighlight }) => {
43 | const [state, dispatch] = useReducer(reducer, initialState);
44 | const {
45 | selecting,
46 | selectedId,
47 | tagName,
48 | attributes,
49 | styles,
50 | selectedStyles,
51 | selectedNote,
52 | } = state;
53 |
54 | const styleChangeHandler = (styles: CSS.Properties) => {
55 | if (selectedId) {
56 | updateStyle(styles);
57 | onHighlight && onHighlight();
58 | if (selectedStyles) {
59 | dispatch({ selectedStyles: styles });
60 | return;
61 | }
62 | }
63 |
64 | dispatch({ styles });
65 | localStorage.setItem('highlight_style', JSON.stringify(styles));
66 | };
67 |
68 | const highlightHandler = () => {
69 | if (selectedId) removeHighlight();
70 | else highlight();
71 | onHighlight && onHighlight();
72 | };
73 |
74 | const {
75 | cancel,
76 | highlight,
77 | removeHighlight,
78 | updateStyle,
79 | updateNote,
80 | } = useHighlightRange({
81 | readerRef,
82 | tagName,
83 | attributes,
84 | styles,
85 | stateChangeCallback: dispatch,
86 | });
87 |
88 | const tabs: TabItem[] = [
89 | {
90 | id: 'highlight',
91 | name: 'highlight',
92 | icon: props => (
93 | }
96 | active={Boolean(selectedId)}
97 | />
98 | ),
99 | isButton: true,
100 | onClick: highlightHandler,
101 | },
102 | {
103 | id: 'styles',
104 | name: 'styles',
105 | icon: props => } />,
106 | content: (
107 |
111 | ),
112 | },
113 | {
114 | id: 'note',
115 | name: 'note',
116 | icon: props => (
117 | }
120 | disabled={selectedId === null}
121 | />
122 | ),
123 | content: (
124 | {
127 | updateNote(note);
128 | onHighlight && onHighlight();
129 | }}
130 | />
131 | ),
132 | },
133 | {
134 | id: 'dismiss',
135 | name: 'dismiss',
136 | icon: props => } />,
137 | isButton: true,
138 | onClick: cancel,
139 | },
140 | ];
141 |
142 | useEffect(() => {
143 | const localStyles = localStorage.getItem('highlight_style');
144 | localStyles && dispatch({ styles: JSON.parse(localStyles) });
145 | }, []);
146 |
147 | return (
148 |
154 | );
155 | };
156 |
157 | const reducer = (state: State, payload: Partial): State => ({
158 | ...state,
159 | ...payload,
160 | });
161 |
--------------------------------------------------------------------------------
/src/components/Hightlight/styles.ts:
--------------------------------------------------------------------------------
1 | import { TextStyle } from './types';
2 |
3 | export const colors = [
4 | 'rgb(238, 238, 238)',
5 | 'rgb(34, 40, 49)',
6 | 'rgb(0, 0, 0)',
7 | 'rgb(254, 39, 18)',
8 | 'rgb(253, 83, 8)',
9 | 'rgb(251, 153, 2)',
10 | 'rgb(250, 188, 2)',
11 | 'rgb(240, 240, 48)',
12 | 'rgb(208, 234, 43)',
13 | 'rgb(102, 176, 50)',
14 | 'rgb(0, 136, 170)',
15 | 'rgb(2, 71, 254)',
16 | 'rgb(61, 1, 164)',
17 | 'rgb(134, 1, 175)',
18 | 'rgb(167, 25, 75)',
19 | ];
20 |
21 | export const textStyles: TextStyle[] = [
22 | {
23 | id: 'bold',
24 | name: 'B',
25 | style: { fontWeight: 'bold' },
26 | itemStyle: { fontWeight: 'bold' },
27 | },
28 | {
29 | id: 'oblique',
30 | name: 'I',
31 | style: { fontStyle: 'oblique' },
32 | itemStyle: { fontStyle: 'oblique' },
33 | },
34 | {
35 | id: 'underline',
36 | name: 'U',
37 | style: { textDecorationLine: 'underline' },
38 | itemStyle: { textDecorationLine: 'underline' },
39 | },
40 | {
41 | id: 'overline',
42 | name: 'O',
43 | style: { textDecorationLine: 'overline' },
44 | itemStyle: { textDecorationLine: 'overline' },
45 | },
46 | {
47 | id: 'linethrough',
48 | name: 'S',
49 | style: { textDecorationLine: 'line-through' },
50 | itemStyle: { textDecorationLine: 'line-through' },
51 | },
52 | {
53 | id: 'undeover',
54 | name: 'T',
55 | style: { textDecorationLine: 'underline overline' },
56 | itemStyle: { textDecorationLine: 'underline overline' },
57 | },
58 | {
59 | id: 'dashed',
60 | name: 'D',
61 | style: { textDecorationStyle: 'dashed' },
62 | itemStyle: {
63 | textDecorationLine: 'underline',
64 | textDecorationStyle: 'dashed',
65 | },
66 | },
67 | {
68 | id: 'wavy',
69 | name: 'W',
70 | style: { textDecorationStyle: 'wavy' },
71 | itemStyle: { textDecorationLine: 'underline', textDecorationStyle: 'wavy' },
72 | },
73 | ];
74 |
--------------------------------------------------------------------------------
/src/components/Hightlight/types.ts:
--------------------------------------------------------------------------------
1 | import * as CSS from 'csstype';
2 |
3 | export type TextStyle = {
4 | id: string;
5 | name: string;
6 | style: CSS.Properties;
7 | itemStyle: CSS.Properties;
8 | };
9 |
--------------------------------------------------------------------------------
/src/components/Hightlight/utils.ts:
--------------------------------------------------------------------------------
1 | export function isin(
2 | a: { [key: string]: string | number },
3 | b: { [key: string]: string | number }
4 | ) {
5 | for (let k in a) if (!(k in b) || a[k] !== b[k]) return false;
6 | return true;
7 | }
8 |
--------------------------------------------------------------------------------
/src/components/Menu/Container.tsx:
--------------------------------------------------------------------------------
1 | import { useRef, useLayoutEffect } from 'react';
2 | import { createPortal } from 'react-dom';
3 | import styled from 'styled-components';
4 | import { motion } from 'framer-motion';
5 | import { usePosition, PositionData } from '../../hooks/usePosition';
6 |
7 | interface Props {
8 | togglerRef: React.MutableRefObject;
9 | setDelayDirection: React.MutableRefObject<1 | -1>;
10 | className?: string;
11 | onOutsideClick?: (
12 | event: React.MouseEvent
13 | ) => void;
14 | }
15 |
16 | export const Container: React.FC = ({
17 | children,
18 | togglerRef,
19 | setDelayDirection,
20 | onOutsideClick,
21 | className,
22 | }) => {
23 | const containerRef = useRef(null);
24 | const position = useRef(null);
25 |
26 | usePosition(togglerRef, containerRef, data => (position.current = data));
27 |
28 | useLayoutEffect(() => {
29 | if (!position.current || !containerRef.current) return;
30 | const {
31 | left,
32 | right,
33 | top,
34 | bottom,
35 | transformOriginX,
36 | transformOriginY,
37 | } = position.current;
38 | const container = containerRef.current;
39 |
40 | // set transform origin
41 | container.style.transformOrigin = `${transformOriginX} ${transformOriginY}`;
42 |
43 | // set left or right
44 | if (left != null) container.style.left = left + 'px';
45 | else container.style.right = right + 'px';
46 |
47 | // set top or bottom
48 | if (top != null) {
49 | container.style.top = top + 'px';
50 | setDelayDirection.current = 1;
51 | } else {
52 | container.style.bottom = bottom + 'px';
53 | setDelayDirection.current = -1;
54 | }
55 | }, [setDelayDirection]);
56 |
57 | return createPortal(
58 | <>
59 |
60 |
69 |
70 | {children}
71 |
72 | >,
73 | document.body
74 | );
75 | };
76 |
77 | const ScContainer = styled(motion.div)`
78 | position: fixed;
79 | z-index: 900;
80 | `;
81 |
82 | const ScBackdrop = styled.div`
83 | position: fixed;
84 | width: 100%;
85 | height: 100%;
86 | top: 0;
87 | left: 0;
88 | background: none;
89 | z-index: 899;
90 | overflow: hidden;
91 | `;
92 |
93 | const ScBackground = styled(motion.div)`
94 | position: absolute;
95 | width: 100%;
96 | height: 100%;
97 | background-color: ${props => props.theme.col5};
98 | border-radius: 8px;
99 | top: 0;
100 | left: 0;
101 | right: 0;
102 | bottom: 0;
103 | z-index: -1;
104 | box-shadow: 0 0 8px rgba(0, 0, 0, 0.2);
105 | `;
106 |
107 | const ScItems = styled.div``;
108 |
--------------------------------------------------------------------------------
/src/components/Menu/Controls.tsx:
--------------------------------------------------------------------------------
1 | import styled, { keyframes } from 'styled-components';
2 | import { rgba } from 'polished';
3 | import { motion } from 'framer-motion';
4 | import { darken } from 'polished';
5 | import { ControlItem } from './types';
6 |
7 | interface Props {
8 | controls: ControlItem[];
9 | className?: string;
10 | }
11 |
12 | export const Controls: React.FC = ({ controls, className }) => {
13 | const _controls = controls.map((item, index) => (
14 | !item.disabled && item.onClick && item.onClick()}
17 | disabled={item.disabled}
18 | $duration={index * 100 + 500}
19 | >
20 |
21 | {item.icon}
22 |
23 |
24 | ));
25 |
26 | return (
27 |
28 |
29 | {_controls}
30 |
31 | );
32 | };
33 |
34 | const ScContainer = styled.div`
35 | display: flex;
36 | position: relative;
37 | `;
38 |
39 | const ScBg = styled(motion.div)`
40 | position: absolute;
41 | width: 100%;
42 | height: 100%;
43 | top: 0;
44 | left: 0;
45 | right: 0;
46 | bottom: 0;
47 | background-color: ${p => darken(0.01, p.theme.col5)};
48 | z-index: -1;
49 | `;
50 |
51 | const slidein = keyframes`
52 | 0% {
53 | transform: translateX(20px); }
54 | 100% {
55 | transform: none; }
56 | `;
57 |
58 | const ScControlItem = styled.button<{ $duration: number }>`
59 | display: flex;
60 | flex-grow: 1;
61 | justify-content: center;
62 | font: inherit;
63 | border: none;
64 | padding: 4px;
65 | background-color: transparent;
66 | cursor: pointer;
67 | transform: translateY(20px);
68 | animation-duration: ${p => p.$duration}ms;
69 | animation-fill-mode: forwards;
70 | animation-name: ${slidein};
71 | animation-timing-function: ease-in-out;
72 | transition: opacity 300ms ease-in-out;
73 |
74 | &:focus,
75 | &:active {
76 | outline: none;
77 | & > div {
78 | background-color: ${p => rgba(p.theme.col2, 0.2)};
79 | transition: background-color 300ms ease-in-out;
80 | }
81 | }
82 | &:disabled {
83 | cursor: not-allowed;
84 | opacity: 0.2;
85 | transition: opacity 300ms ease-in-out;
86 | }
87 | @media (hover: hover) and (pointer: fine) {
88 | &:hover:not(:disabled) {
89 | & > div {
90 | background-color: ${p => rgba(p.theme.col2, 0.2)};
91 | transition: background-color 300ms ease-in-out;
92 | }
93 | }
94 | }
95 | `;
96 |
97 | const ScIcon = styled(motion.div)`
98 | width: 36px;
99 | height: 36px;
100 | padding: 8px;
101 | color: ${p => p.theme.col2};
102 | fill: ${p => p.theme.col2};
103 | stroke: ${p => p.theme.col2};
104 | background-color: transparent;
105 | transition: background-color 300ms ease-in-out;
106 | border-radius: 50%;
107 |
108 | &:focus,
109 | &:active {
110 | outline: none;
111 | }
112 | `;
113 |
--------------------------------------------------------------------------------
/src/components/Menu/Items.tsx:
--------------------------------------------------------------------------------
1 | import { useLayoutEffect, useState } from 'react';
2 | import styled, { css, keyframes } from 'styled-components';
3 | import { rgba } from 'polished';
4 | import { MenuItem, ControlItem } from './types';
5 | import { ChevronForward } from '../../assets/icons/essentials';
6 | import { Controls } from './Controls';
7 |
8 | interface Props {
9 | items: MenuItem[];
10 | onSubActive: (items: MenuItem[]) => void;
11 | controlItems: ControlItem[];
12 | getDelayDirection: React.MutableRefObject<1 | -1>;
13 | onClick: () => void;
14 | }
15 |
16 | export const Items: React.FC = ({
17 | items,
18 | onSubActive,
19 | controlItems,
20 | getDelayDirection,
21 | onClick,
22 | }) => {
23 | const [play, setPlay] = useState(false);
24 | const fromTop = getDelayDirection.current === 1;
25 | useLayoutEffect(() => setPlay(true), []);
26 |
27 | const clickHandler = ({
28 | disabled,
29 | items,
30 | isSubMenu,
31 | onClick: itemOnClick,
32 | }: MenuItem) => () => {
33 | if (disabled) return;
34 | isSubMenu && items && items.length > 0 && onSubActive(items);
35 | !isSubMenu && itemOnClick && itemOnClick();
36 | !isSubMenu && onClick();
37 | };
38 |
39 | const _items = items.map((item, index) => (
40 |
47 |
48 | {item.icon}
49 | {item.name}
50 | {item.isSubMenu && (
51 |
52 |
53 |
54 | )}
55 |
56 |
57 | ));
58 |
59 | return (
60 |
61 |
62 |
63 | {_items}
64 |
65 |
66 | );
67 | };
68 |
69 | const ScContainer = styled.div<{ $fromTop: boolean }>`
70 | display: flex;
71 | flex-direction: ${p => (p.$fromTop ? 'column' : 'column-reverse')};
72 | `;
73 |
74 | const ScControls = styled(Controls)<{ $fromTop: boolean }>(
75 | ({ $fromTop }) => css`
76 | & > div {
77 | border-radius: ${$fromTop ? '8px 8px 0 0' : '0 0 8px 8px'};
78 | border-top: ${$fromTop ? 'none' : '1px solid rgba(0, 0, 0, 0.1)'};
79 | border-bottom: ${$fromTop ? '1px solid rgba(0, 0, 0, 0.1)' : 'none'};
80 | }
81 | `
82 | );
83 |
84 | const ScItems = styled.div<{ $play: boolean; $fromTop: boolean }>(
85 | ({ $play, $fromTop }) => css`
86 | border-radius: ${$fromTop ? '0 0 8px 8px' : '8px 8px 0 0'};
87 | animation-play-state: ${$play ? 'running' : 'paused'};
88 | overflow: hidden;
89 | `
90 | );
91 |
92 | const slidetop = keyframes`
93 | 0% { opacity: 0; transform: translateY(-10px); }
94 | 100% { opacity: 1; transform: none; }
95 | `;
96 |
97 | const slidebottom = keyframes`
98 | 0% { opacity: 0; transform: translateY(10px); }
99 | 100% { opacity: 1; transform: none; }
100 | `;
101 |
102 | const ScItem = styled.button<{ $delay: number; $fromTop: boolean }>(
103 | ({ $delay, $fromTop, theme }) => css`
104 | width: 100%;
105 | font: inherit;
106 | border: none;
107 | background-color: transparent;
108 | padding: 0;
109 | display: flex;
110 | text-align: left;
111 | color: ${theme.col1};
112 | cursor: pointer;
113 | opacity: 0;
114 | transform: translateY(${$fromTop ? -10 : 10}px);
115 | animation-delay: ${$delay}ms;
116 | animation-direction: normal;
117 | animation-duration: 300ms;
118 | animation-fill-mode: forwards;
119 | animation-iteration-count: 1;
120 | animation-name: ${$fromTop ? slidetop : slidebottom};
121 | animation-play-state: inherit;
122 | animation-timing-function: ease-in-out;
123 |
124 | &:focus,
125 | &:active {
126 | outline: none;
127 | & > div {
128 | background-color: ${rgba(theme.col2, 0.2)};
129 | transition: background-color 300ms ease-in-out;
130 | }
131 | }
132 | &:disabled {
133 | cursor: not-allowed;
134 | }
135 | @media (hover: hover) and (pointer: fine) {
136 | &:hover:not(:disabled) {
137 | & > div {
138 | background-color: ${rgba(theme.col2, 0.2)};
139 | transition: background-color 300ms ease-in-out;
140 | }
141 | }
142 | }
143 | `
144 | );
145 |
146 | const ScFocus = styled.div`
147 | width: 100%;
148 | height: 100%;
149 | padding: 12px;
150 | display: flex;
151 | align-items: center;
152 | background-color: transparent;
153 | transition: background-color 300ms ease-in-out;
154 |
155 | &:focus,
156 | &:active {
157 | outline: none;
158 | }
159 | `;
160 |
161 | const ScIcon = styled.div<{ $disabled?: boolean }>(
162 | ({ $disabled, theme }) => css`
163 | width: 20px;
164 | height: 20px;
165 | margin-right: 12px;
166 | color: ${theme.col2};
167 | fill: ${theme.col2};
168 | stroke: ${theme.col2};
169 | opacity: ${$disabled ? 0.2 : 1};
170 | flex-shrink: 0;
171 | `
172 | );
173 |
174 | const ScName = styled.div<{ $disabled?: boolean }>`
175 | flex-grow: 1;
176 | opacity: ${p => (p.$disabled ? 0.2 : 1)};
177 | `;
178 |
179 | const ScMore = styled.div<{ $disabled?: boolean }>(
180 | ({ $disabled, theme }) => css`
181 | width: 20px;
182 | height: 20px;
183 | margin-left: 12px;
184 | color: ${theme.col2};
185 | fill: ${theme.col2};
186 | stroke: ${theme.col2};
187 | opacity: ${$disabled ? 0.2 : 1};
188 | flex-shrink: 0;
189 | `
190 | );
191 |
--------------------------------------------------------------------------------
/src/components/Menu/Menu.tsx:
--------------------------------------------------------------------------------
1 | import { useRef, useReducer } from 'react';
2 | import styled from 'styled-components';
3 | import { rgba } from 'polished';
4 | import { motion, AnimatePresence } from 'framer-motion';
5 | import { MenuItem, ControlItem } from './types';
6 | import {
7 | EllipsisHorizontal,
8 | ChevronForward,
9 | ChevronBack,
10 | Close,
11 | } from '../../assets/icons/essentials';
12 | import { Container } from './Container';
13 | import { Items } from './Items';
14 |
15 | interface Props {
16 | name?: string;
17 | disabled?: boolean;
18 | icon?: JSX.Element;
19 | togglerIcon?: JSX.Element;
20 | items?: MenuItem[];
21 | hideOnClick?: boolean;
22 | }
23 |
24 | interface State {
25 | activeItems: MenuItem[];
26 | prevItems: MenuItem[][];
27 | forwardItems: MenuItem[][];
28 | show: boolean;
29 | }
30 |
31 | export const Menu: React.FC = ({
32 | name,
33 | icon,
34 | disabled = false,
35 | togglerIcon = ,
36 | items = [],
37 | hideOnClick = true,
38 | }) => {
39 | const [state, dispatch] = useReducer(reducer, {
40 | activeItems: items,
41 | prevItems: [],
42 | forwardItems: [],
43 | show: false,
44 | });
45 |
46 | const { activeItems, prevItems, forwardItems, show } = state;
47 | const togglerRef = useRef(null);
48 | const delayDirection = useRef<1 | -1>(1);
49 |
50 | const togglerShow = () => {
51 | if (disabled || items.length === 0) return;
52 | if (show) {
53 | dispatch({
54 | show: false,
55 | activeItems: items,
56 | prevItems: [],
57 | forwardItems: [],
58 | });
59 | } else dispatch({ show: true });
60 | };
61 |
62 | const subActiveHandler = (items: MenuItem[]) => {
63 | dispatch({
64 | activeItems: items,
65 | prevItems: [...prevItems, activeItems],
66 | forwardItems: [],
67 | });
68 | };
69 |
70 | const prevHandler = () => {
71 | const active = prevItems.pop();
72 | dispatch({
73 | activeItems: active,
74 | prevItems,
75 | forwardItems: [...forwardItems, activeItems],
76 | });
77 | };
78 |
79 | const forwardHandler = () => {
80 | const active = forwardItems.pop();
81 | dispatch({
82 | activeItems: active,
83 | forwardItems,
84 | prevItems: [...prevItems, activeItems],
85 | });
86 | };
87 |
88 | const control_items: ControlItem[] = [
89 | {
90 | id: 'back',
91 | icon: ,
92 | disabled: prevItems.length === 0,
93 | onClick: prevHandler,
94 | },
95 | {
96 | id: 'forward',
97 | icon: ,
98 | disabled: forwardItems.length === 0,
99 | onClick: forwardHandler,
100 | },
101 | {
102 | id: 'exit',
103 | icon: ,
104 | onClick: togglerShow,
105 | },
106 | ];
107 |
108 | return (
109 |
110 |
111 |
112 | {icon && {icon}}
113 | {name && {name}}
114 |
115 | {togglerIcon}
116 |
117 |
118 |
119 |
120 |
121 | {show && (
122 |
127 | hideOnClick && togglerShow()}
133 | />
134 |
135 | )}
136 |
137 |
138 | );
139 | };
140 |
141 | const reducer = (state: State, payload: Partial): State => ({
142 | ...state,
143 | ...payload,
144 | });
145 |
146 | const ScContainer = styled.div`
147 | display: inline-flex;
148 | `;
149 |
150 | const ScItemsContainer = styled(Container)`
151 | color: ${p => rgba(p.theme.col1, 0.5)};
152 | `;
153 |
154 | const ScToggler = styled.button`
155 | font: inherit;
156 | border: none;
157 | background-color: transparent;
158 | padding: 0;
159 | display: flex;
160 | color: ${props => props.theme.col2};
161 | fill: ${props => props.theme.col2};
162 | cursor: pointer;
163 |
164 | &:focus,
165 | &:active {
166 | outline: none;
167 | & > div {
168 | background-color: ${p => rgba(p.theme.col2, 0.2)};
169 | transition: background-color 300ms ease-in-out;
170 | }
171 | }
172 | &:disabled {
173 | opacity: 0.2;
174 | cursor: not-allowed;
175 | }
176 | @media (hover: hover) and (pointer: fine) {
177 | &:hover:not(:disabled) {
178 | & > div {
179 | background-color: ${p => rgba(p.theme.col2, 0.2)};
180 | transition: background-color 300ms ease-in-out;
181 | }
182 | }
183 | }
184 | `;
185 |
186 | const ScFocus = styled.div`
187 | width: 100%;
188 | height: 100%;
189 | padding: 8px;
190 | border-radius: 8px;
191 | display: flex;
192 | align-items: center;
193 | background-color: transparent;
194 | transition: background-color 300ms ease-in-out;
195 |
196 | &:focus,
197 | &:active {
198 | outline: none;
199 | }
200 | `;
201 |
202 | const ScTogglerIcon = styled(motion.div)`
203 | width: 20px;
204 | height: 20px;
205 | `;
206 |
207 | const ScMenuName = styled.div`
208 | text-align: left;
209 | margin-right: 8px;
210 | `;
211 |
212 | const ScMenuIcon = styled.div`
213 | width: 16px;
214 | height: 16px;
215 | margin-right: 8px;
216 | `;
217 |
--------------------------------------------------------------------------------
/src/components/Menu/index.ts:
--------------------------------------------------------------------------------
1 | export * from './Menu';
2 | export * from './types';
3 |
--------------------------------------------------------------------------------
/src/components/Menu/types.ts:
--------------------------------------------------------------------------------
1 | export interface MenuItem {
2 | id: string;
3 | disabled?: boolean;
4 | icon?: JSX.Element;
5 | isSubMenu?: boolean;
6 | items?: MenuItem[];
7 | name: string;
8 | onClick?: () => void;
9 | }
10 |
11 | export interface ControlItem {
12 | id: string;
13 | icon: JSX.Element;
14 | disabled?: boolean;
15 | onClick?: () => void;
16 | }
17 |
--------------------------------------------------------------------------------
/src/components/SlideDrawer/Backdrop.tsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import { motion, MotionValue, useTransform } from 'framer-motion';
3 |
4 | interface Props {
5 | progress: MotionValue;
6 | onClick?: () => void;
7 | }
8 |
9 | export const Backdrop: React.FC = ({ progress, onClick }) => {
10 | const opacity = useTransform(progress, [0.1, 0.9], [0, 1]);
11 | const pointerEvents = useTransform(progress, pointerTransformer);
12 | return ;
13 | };
14 |
15 | const pointerTransformer = (value: number) => {
16 | if (value < 0.9) return 'none';
17 | else return 'all';
18 | };
19 |
20 | const ScBackdrop = styled(motion.div)`
21 | position: fixed;
22 | width: 100%;
23 | height: 100%;
24 | z-index: 99;
25 | top: 0;
26 | right: 0;
27 | bottom: 0;
28 | left: 0;
29 | background-color: rgba(0, 0, 0, 0.3);
30 | `;
31 |
--------------------------------------------------------------------------------
/src/components/SlideDrawer/SideDrawer.tsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import { motion } from 'framer-motion';
3 | import { TogglerButton } from './TogglerButton';
4 | import { Backdrop } from './Backdrop';
5 | import { SDContent } from './content/SDContent';
6 | import { useSideDrawer } from './useSideDrawer';
7 |
8 | interface Props {
9 | containerRef: React.MutableRefObject;
10 | }
11 |
12 | export const SideDrawer: React.FC = ({ containerRef }) => {
13 | const { opened, motionX, progress, toggle } = useSideDrawer(containerRef);
14 |
15 | return (
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | );
24 | };
25 |
26 | const ScContainer = styled.div``;
27 |
28 | const ScSideDrawer = styled(motion.div)`
29 | position: fixed;
30 | left: 0;
31 | top: 0;
32 | bottom: 0;
33 | width: 80%;
34 | height: 100%;
35 | transform: translateX(-100%);
36 | background-color: ${p => p.theme.col5};
37 | z-index: 100;
38 | border-right: 1px solid rgba(0, 0, 0, 0.2);
39 |
40 | &::before {
41 | content: '';
42 | position: absolute;
43 | width: 100%;
44 | height: 100%;
45 | top: 0;
46 | left: -99%;
47 | background-color: ${p => p.theme.col5};
48 | }
49 |
50 | @media (min-width: 480px) {
51 | width: 320px;
52 | transform: translateX(-320px);
53 | }
54 | `;
55 |
--------------------------------------------------------------------------------
/src/components/SlideDrawer/TogglerButton.tsx:
--------------------------------------------------------------------------------
1 | import { createPortal } from 'react-dom';
2 | import styled from 'styled-components';
3 | import { rgba } from 'polished';
4 | import { motion, useTransform, MotionValue } from 'framer-motion';
5 |
6 | const d_middle = 'M 3,13 23,13';
7 | const d_top = ['M 13,5 23,5', 'M 13,13 20.07106,5.92894'];
8 |
9 | interface Props {
10 | onClick?: () => void;
11 | progress: MotionValue;
12 | }
13 |
14 | export const TogglerButton: React.FC = ({ onClick, progress }) => {
15 | const d = useTransform(progress, [0, 1], d_top);
16 | const middleRotate = useTransform(progress, [0, 1], [0, 45]);
17 | const rotate = useTransform(progress, v => v * 90);
18 |
19 | return createPortal(
20 |
21 |
22 |
23 |
24 |
25 |
32 |
33 |
34 |
35 | ,
36 | document.body
37 | );
38 | };
39 |
40 | const ScContainer = styled.div`
41 | position: absolute;
42 | z-index: 110;
43 | width: 64px;
44 | height: 64px;
45 | top: 0;
46 | left: 0;
47 | `;
48 |
49 | const ScInner = styled.div`
50 | width: 100%;
51 | height: 100%;
52 | position: relative;
53 | display: flex;
54 | align-items: center;
55 | justify-content: center;
56 | `;
57 |
58 | const ScToggler = styled.button`
59 | width: 44px;
60 | height: 44px;
61 | border: none;
62 | padding: 0;
63 | background-color: transparent;
64 | cursor: pointer;
65 | z-index: 1;
66 |
67 | &:focus,
68 | &:active {
69 | outline: none;
70 | & > div {
71 | background-color: ${p => rgba(p.theme.col2, 0.2)};
72 | transition: background-color 300ms ease-in-out;
73 | }
74 | }
75 | `;
76 |
77 | const ScFocus = styled.div`
78 | width: 100%;
79 | height: 100%;
80 | padding: 10px;
81 | border-radius: 8px;
82 | display: flex;
83 | background-color: transparent;
84 | transition: background-color 300ms ease-in-out;
85 |
86 | &:focus,
87 | &:active {
88 | outline: none;
89 | }
90 | `;
91 |
92 | const ScBackground = styled(motion.div)`
93 | position: absolute;
94 | width: 100%;
95 | height: 100%;
96 | border-radius: 50%;
97 | background-image: radial-gradient(${p => p.theme.col5} 25%, transparent 75%);
98 | `;
99 |
100 | const ScPath = styled(motion.path)`
101 | stroke: ${p => p.theme.col2};
102 | stroke-width: 2;
103 | stroke-linecap: round;
104 | transform-origin: center;
105 | `;
106 |
107 | const ScPathBottom = styled(ScPath)`
108 | transform: rotate(180deg);
109 | `;
110 |
--------------------------------------------------------------------------------
/src/components/SlideDrawer/content/Navs/Navitem.tsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import { rgba } from 'polished';
3 |
4 | interface Props {
5 | disabled?: boolean;
6 | externalLink?: boolean;
7 | icon: JSX.Element;
8 | name: string;
9 | onClick?: () => void;
10 | focusable: boolean;
11 | }
12 |
13 | export const Navitem: React.FC = ({
14 | icon,
15 | name,
16 | onClick,
17 | disabled,
18 | focusable = true,
19 | }) => {
20 | return (
21 |
26 |
27 | {icon}
28 | {name}
29 |
30 |
31 | );
32 | };
33 |
34 | const ScNavitem = styled.button`
35 | width: 100%;
36 | font: inherit;
37 | border: none;
38 | background-color: transparent;
39 | cursor: pointer;
40 | color: ${p => p.theme.col1};
41 | padding: 0;
42 | display: flex;
43 | align-items: center;
44 | transition: background-color 300ms ease-in-out;
45 |
46 | &:focus,
47 | &:active {
48 | outline: none;
49 | & > div {
50 | background-color: ${p => rgba(p.theme.col2, 0.2)};
51 | transition: background-color 300ms ease-in-out;
52 | }
53 | }
54 | &:disabled {
55 | cursor: not-allowed;
56 | opacity: 0.2;
57 | transition: opacity 300ms ease-in-out;
58 | }
59 |
60 | @media (hover: hover) and (pointer: fine) {
61 | &:hover:not(:disabled) {
62 | & > div {
63 | background-color: ${p => rgba(p.theme.col2, 0.2)};
64 | transition: background-color 300ms ease-in-out;
65 | }
66 | }
67 | }
68 | `;
69 |
70 | const ScFocus = styled.div`
71 | width: 100%;
72 | height: 100%;
73 | padding: 12px 22px;
74 | display: flex;
75 | align-items: center;
76 | background-color: transparent;
77 | transition: background-color 300ms ease-in-out;
78 |
79 | &:focus,
80 | &:active {
81 | outline: none;
82 | }
83 | `;
84 |
85 | const ScIcon = styled.div`
86 | width: 20px;
87 | height: 20px;
88 | fill: ${p => p.theme.col2};
89 | stroke: ${p => p.theme.col2};
90 | margin-right: 12px;
91 | `;
92 |
93 | const ScName = styled.div``;
94 |
--------------------------------------------------------------------------------
/src/components/SlideDrawer/content/Navs/NavitemsGroup.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useContext } from 'react';
2 | import styled from 'styled-components';
3 | import { rgba } from 'polished';
4 | import { motion, Variants } from 'framer-motion';
5 |
6 | import { NavitemI } from './types';
7 | import { Navitem } from './Navitem';
8 | import { ChevronForward } from '../../../../assets/icons/essentials';
9 | import { SDContext } from '../SDContent';
10 |
11 | interface Props {
12 | name: string;
13 | collapsable?: boolean;
14 | collapsed?: boolean;
15 | items: NavitemI[];
16 | }
17 |
18 | export const NavitemsGroup: React.FC = ({
19 | collapsable = false,
20 | collapsed = false,
21 | items,
22 | name,
23 | }) => {
24 | const [expanded, setExpanded] = useState(collapsable ? !collapsed : true);
25 | const { opened } = useContext(SDContext);
26 | const toggler = () => setExpanded(p => !p);
27 | const _items = items.map(({ id, ...rest }) => (
28 |
29 | ));
30 |
31 | return (
32 |
33 |
34 |
35 | {name}
36 |
37 |
38 | {collapsable && }
39 | {collapsable && (
40 |
41 |
42 |
46 |
47 |
48 |
49 |
50 | )}
51 |
52 |
57 | {_items}
58 |
59 |
60 | );
61 | };
62 |
63 | const variants: Variants = {
64 | hide: {
65 | height: 0,
66 | opacity: 0,
67 | transition: { type: 'spring', stiffness: 300, damping: 40 },
68 | },
69 | show: {
70 | height: 'auto',
71 | opacity: 1,
72 | transition: { type: 'spring', stiffness: 300, damping: 40 },
73 | },
74 | };
75 |
76 | const ScContainer = styled.div`
77 | width: 100%;
78 | `;
79 |
80 | const ScHeading = styled.div`
81 | width: 100%;
82 | display: grid;
83 | grid-template-columns: 1fr auto 1fr auto;
84 | grid-template-rows: auto;
85 | align-items: center;
86 | position: relative;
87 | padding: 12px;
88 | `;
89 |
90 | const ScName = styled.div`
91 | color: ${p => p.theme.col2};
92 | padding: 0 8px;
93 | `;
94 |
95 | const ScBar = styled.div`
96 | height: 1px;
97 | background-color: ${p => rgba(p.theme.col2, 0.3)};
98 | width: 100%;
99 | `;
100 |
101 | const ScExpandButtonBg = styled.div`
102 | width: 52px;
103 | height: 20px;
104 | position: absolute;
105 | background-color: ${p => p.theme.col5};
106 | right: 0;
107 | `;
108 |
109 | const ScExpandButton = styled.button`
110 | position: absolute;
111 | font: inherit;
112 | border: none;
113 | width: 36px;
114 | height: 36px;
115 | padding: 0;
116 | display: flex;
117 | cursor: pointer;
118 | background-color: transparent;
119 | transition: background-color 300ms ease-in-out;
120 | right: 8px;
121 |
122 | &:focus,
123 | &:active {
124 | outline: none;
125 | & > div {
126 | background-color: ${p => rgba(p.theme.col2, 0.2)};
127 | transition: background-color 300ms ease-in-out;
128 | }
129 | }
130 |
131 | @media (hover: hover) and (pointer: fine) {
132 | &:hover:not(:disabled) {
133 | & > div {
134 | background-color: ${p => rgba(p.theme.col2, 0.2)};
135 | transition: background-color 300ms ease-in-out;
136 | }
137 | }
138 | }
139 | `;
140 |
141 | const ScFocus = styled.div`
142 | width: 100%;
143 | height: 100%;
144 | padding: 8px;
145 | border-radius: 50%;
146 | display: flex;
147 | background-color: transparent;
148 | transition: background-color 300ms ease-in-out;
149 |
150 | &:focus,
151 | &:active {
152 | outline: none;
153 | }
154 | `;
155 |
156 | const ScArrowIcon = styled(motion.div)`
157 | width: 20px;
158 | height: 20px;
159 | transform-origin: center;
160 | fill: ${p => p.theme.col2};
161 | stroke: ${p => p.theme.col2};
162 | color: ${p => p.theme.col2};
163 | `;
164 |
165 | const ScItemsContainer = styled(motion.div)`
166 | width: 100%;
167 | overflow: hidden;
168 | `;
169 |
--------------------------------------------------------------------------------
/src/components/SlideDrawer/content/Navs/Navs.tsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | import { NavitemsGroupI } from './types';
4 | import { sample_navs } from './sample_navs';
5 | import { NavitemsGroup } from './NavitemsGroup';
6 |
7 | interface Props {
8 | navs?: NavitemsGroupI[];
9 | }
10 |
11 | export const Navs: React.FC = ({ navs = sample_navs }) => {
12 | const _navs = navs.map(itemsGroup => (
13 |
20 | ));
21 | return {_navs};
22 | };
23 |
24 | const ScContainer = styled.div`
25 | width: 100%;
26 | padding: 8px 0;
27 | `;
28 |
--------------------------------------------------------------------------------
/src/components/SlideDrawer/content/Navs/index.ts:
--------------------------------------------------------------------------------
1 | export * from './Navs';
2 |
--------------------------------------------------------------------------------
/src/components/SlideDrawer/content/Navs/sample_navs.tsx:
--------------------------------------------------------------------------------
1 | import { v4 as uuid } from 'uuid';
2 | import { NavitemsGroupI } from './types';
3 | import {
4 | AtCircle,
5 | Bag,
6 | BarChart,
7 | ColorPalette,
8 | Dice,
9 | GameController,
10 | Home,
11 | Settings,
12 | Snow,
13 | Storefront,
14 | Tennisball,
15 | Trophy,
16 | } from '../../../../assets/icons/essentials';
17 |
18 | const mainNavs: NavitemsGroupI = {
19 | id: uuid(),
20 | name: 'Main',
21 | collapsable: false,
22 | items: [
23 | { id: uuid(), name: 'Home', icon: },
24 | { id: uuid(), name: 'App Theme', icon: },
25 | { id: uuid(), name: 'Settings', icon: },
26 | { id: uuid(), name: 'Messages', icon: },
27 | ],
28 | };
29 |
30 | const shopNavs: NavitemsGroupI = {
31 | id: uuid(),
32 | name: 'Shop',
33 | collapsable: false,
34 | items: [
35 | { id: uuid(), name: 'Store', icon: },
36 | { id: uuid(), name: 'Bags', icon: },
37 | { id: uuid(), name: 'Offers', icon: },
38 | { id: uuid(), name: 'Gifts', icon: },
39 | ],
40 | };
41 |
42 | const otherNavs: NavitemsGroupI = {
43 | id: uuid(),
44 | name: 'More',
45 | collapsable: true,
46 | collapsed: false,
47 | items: [
48 | { id: uuid(), name: 'Bar chart', icon: },
49 | { id: uuid(), name: 'Dice', icon: },
50 | { id: uuid(), name: 'Game Controller', icon: },
51 | { id: uuid(), name: 'Tennis Ball', icon: },
52 | ],
53 | };
54 |
55 | export const sample_navs: NavitemsGroupI[] = [mainNavs, shopNavs, otherNavs];
56 |
--------------------------------------------------------------------------------
/src/components/SlideDrawer/content/Navs/types.ts:
--------------------------------------------------------------------------------
1 | export interface NavitemI {
2 | id: string;
3 | disabled?: boolean;
4 | externalLink?: boolean;
5 | icon: JSX.Element;
6 | name: string;
7 | onClick?: () => void;
8 | }
9 |
10 | export interface NavitemsGroupI {
11 | id: string;
12 | name: string;
13 | collapsable?: boolean;
14 | collapsed?: boolean;
15 | items: NavitemI[];
16 | }
17 |
--------------------------------------------------------------------------------
/src/components/SlideDrawer/content/Profile/Profile.tsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import { darken } from 'polished';
3 |
4 | import sample_pic from './sample_pic.jpg';
5 |
6 | export const Profile: React.FC = () => {
7 | return (
8 |
9 |
10 |
11 | Welcome ruvkr...
12 | ID568896
13 |
14 |
15 | );
16 | };
17 |
18 | const ScContainer = styled.div`
19 | width: 100%;
20 | height: 80vw;
21 | position: relative;
22 | display: flex;
23 | flex-direction: column;
24 | align-items: center;
25 | justify-content: center;
26 | overflow: hidden;
27 | background-color: ${p => darken(0.01, p.theme.col5)};
28 |
29 | @media (min-width: 480px) {
30 | height: 320px;
31 | }
32 | `;
33 |
34 | const ScProfilePic = styled.div<{ $pic: string }>`
35 | width: 100px;
36 | height: 100px;
37 | border-radius: 50%;
38 | background-color: black;
39 | background-image: ${p => 'url(' + p.$pic + ')'};
40 | background-size: cover;
41 | z-index: 1;
42 | `;
43 |
44 | const ScUserDetails = styled.div`
45 | margin-top: 16px;
46 | display: flex;
47 | flex-direction: column;
48 | align-items: center;
49 | `;
50 |
51 | const ScUserName = styled.div`
52 | color: ${p => p.theme.col1};
53 | font-size: 1.5em;
54 | font-weight: 300;
55 | `;
56 |
57 | const ScUserID = styled.div`
58 | color: ${p => p.theme.col2};
59 | margin-top: 4px;
60 | `;
61 |
--------------------------------------------------------------------------------
/src/components/SlideDrawer/content/Profile/index.ts:
--------------------------------------------------------------------------------
1 | export * from './Profile';
2 |
--------------------------------------------------------------------------------
/src/components/SlideDrawer/content/Profile/sample_pic.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ruvkr/react-components-by-ruvkr/d8c00250eea7feb988619ab1c6f7ddd6927448ac/src/components/SlideDrawer/content/Profile/sample_pic.jpg
--------------------------------------------------------------------------------
/src/components/SlideDrawer/content/QuickSettings.tsx:
--------------------------------------------------------------------------------
1 | import { useContext } from 'react';
2 | import styled from 'styled-components';
3 | import { darken } from 'polished';
4 | import { v4 as uuid } from 'uuid';
5 |
6 | import { SDContext } from './SDContent';
7 | import { Switch } from '../../Switch';
8 |
9 | const sample_settings = [
10 | { id: uuid(), name: 'Dark Mode', on: true, disabled: false },
11 | { id: uuid(), name: 'Receive notifications', on: false, disabled: false },
12 | { id: uuid(), name: 'New settings', on: false, disabled: true },
13 | { id: uuid(), name: 'Other settings', on: true, disabled: true },
14 | ];
15 |
16 | export const QuickSettings: React.FC = () => {
17 | const { opened } = useContext(SDContext);
18 |
19 | const settings = sample_settings.map(item => (
20 |
21 | {item.name}
22 |
23 |
24 | ));
25 |
26 | return {settings};
27 | };
28 |
29 | const ScContainer = styled.div`
30 | width: 100%;
31 | padding: 8px;
32 | background-color: ${p => darken(0.01, p.theme.col5)};
33 | `;
34 |
35 | const ScItem = styled.div`
36 | width: 100%;
37 | display: flex;
38 | align-items: center;
39 | justify-content: space-between;
40 | color: ${p => p.theme.col2};
41 | padding: 12px;
42 | `;
43 |
44 | const ScName = styled.div``;
45 |
--------------------------------------------------------------------------------
/src/components/SlideDrawer/content/SDContent.tsx:
--------------------------------------------------------------------------------
1 | import { createContext } from 'react';
2 | import styled from 'styled-components';
3 | import { MotionValue } from 'framer-motion';
4 |
5 | import { Profile } from './Profile';
6 | import { Navs } from './Navs';
7 | import { QuickSettings } from './QuickSettings';
8 |
9 | interface Props {
10 | opened: boolean;
11 | progress: MotionValue;
12 | }
13 |
14 | interface SDContextI {
15 | opened: boolean;
16 | progress: MotionValue;
17 | }
18 |
19 | export const SDContext = createContext({} as SDContextI);
20 |
21 | export const SDContent: React.FC = ({ opened, progress }) => {
22 | return (
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 | );
31 | };
32 |
33 | const ScContainer = styled.div`
34 | width: 100%;
35 | height: 100%;
36 | overflow: auto;
37 | `;
38 |
--------------------------------------------------------------------------------
/src/components/SlideDrawer/index.ts:
--------------------------------------------------------------------------------
1 | export * from './SideDrawer';
2 |
--------------------------------------------------------------------------------
/src/components/SlideDrawer/useSideDrawer.ts:
--------------------------------------------------------------------------------
1 | import { useRef, useEffect, useReducer } from 'react';
2 | import { MotionValue, useMotionValue } from 'framer-motion';
3 | import { Pan, PanDirections, PanInterface } from '../../libs/pan';
4 | import { Spring, SpringInterface } from '../../libs/spring';
5 | import { useWindowResize } from '../../hooks';
6 |
7 | interface State {
8 | opened: boolean;
9 | width: number;
10 | mobile: boolean;
11 | }
12 |
13 | export function useSideDrawer(
14 | parentRef: React.MutableRefObject
15 | ): {
16 | opened: boolean;
17 | motionX: MotionValue;
18 | progress: MotionValue;
19 | toggle: () => void;
20 | } {
21 | const [state, dispatch] = useReducer(reducer, {
22 | opened: false,
23 | width: getWidth(),
24 | mobile: isMobile(),
25 | });
26 | const { opened, width, mobile } = state;
27 | const openedRef = useRef(opened);
28 | const openedCurrent = useRef(opened);
29 | const panRef = useRef(null);
30 | const springRef = useRef(null);
31 | const motionX = useMotionValue(opened ? 0 : -width);
32 | const progress = useMotionValue(opened ? 1 : 0);
33 | const toggleRef = useRef void)>(null);
34 |
35 | // initializations
36 | useEffect(() => {
37 | if (!parentRef.current) return;
38 | panRef.current = Pan(parentRef.current).add();
39 | springRef.current = Spring();
40 |
41 | return () => {
42 | if (!panRef.current || !springRef.current) return;
43 | springRef.current.stop();
44 | panRef.current.remove();
45 | };
46 | }, [parentRef]);
47 |
48 | // sliding
49 | useEffect(() => {
50 | if (!panRef.current || !springRef.current) return;
51 | const spring = springRef.current;
52 | const pan = panRef.current;
53 | let opened = openedCurrent.current;
54 | let currentValue = motionX.get();
55 | const maxValue = mobile ? window.innerWidth - width : width * 0.4;
56 | let panDirection = opened ? PanDirections.horizontal : PanDirections.right;
57 | let clamped = false;
58 |
59 | if (!spring.isAnimating()) {
60 | currentValue = opened ? 0 : -width;
61 | motionX.set(currentValue);
62 | progress.set(opened ? 1 : 0);
63 | }
64 |
65 | pan.update({
66 | panDirection,
67 |
68 | // check if pan should start
69 | onPanStart: info => {
70 | if (opened && (mobile || info.start.sx < width * 1.2)) return true;
71 | const startRange = mobile ? window.innerWidth * 0.1 : width * 0.2;
72 | if (!opened && info.start.sx < startRange) return true;
73 | return false;
74 | },
75 |
76 | onPanMove: info => {
77 | spring.stop();
78 | const newValue = currentValue + info.delta.dx;
79 | currentValue = clamp(-width, maxValue, newValue);
80 | clamped = newValue !== currentValue;
81 | motionX.set(currentValue);
82 | progress.set(1 + currentValue / width);
83 | },
84 |
85 | onPanEnd: info => {
86 | const velocity = clamped ? 0 : info.velocity.vx;
87 | let change = false;
88 | if (opened && info.direction === PanDirections.left) change = true;
89 | if (!opened && info.direction === PanDirections.right) change = true;
90 | snap(change, velocity);
91 | clamped = false;
92 | },
93 | });
94 |
95 | spring.update({
96 | from: currentValue,
97 | to: opened ? 0 : -width,
98 | stiffness: stiffness,
99 | damping: opened ? dampingOpen : dampingClose,
100 |
101 | onUpdate: value => {
102 | currentValue = value;
103 | motionX.set(currentValue);
104 | progress.set(1 + currentValue / width);
105 | },
106 |
107 | onComplete: () => {
108 | if (opened === openedRef.current) return;
109 | openedRef.current = opened;
110 | dispatch({ opened });
111 | },
112 | });
113 |
114 | const snap = (change: boolean = false, initialVelocity?: number) => {
115 | if (change) {
116 | opened = !opened;
117 | openedCurrent.current = opened;
118 | panDirection = opened ? PanDirections.horizontal : PanDirections.right;
119 | pan.update({ panDirection });
120 | }
121 |
122 | spring.update({
123 | from: currentValue,
124 | to: opened ? 0 : -width,
125 | initialVelocity,
126 | damping: opened ? dampingOpen : dampingClose,
127 | });
128 |
129 | spring.start();
130 | };
131 |
132 | toggleRef.current = () => snap(true);
133 | }, [width, mobile, motionX, progress]);
134 |
135 | useWindowResize(() => {
136 | dispatch({ width: getWidth(), mobile: isMobile() });
137 | }, 100);
138 |
139 | const toggle = () => {
140 | if (!toggleRef.current) return;
141 | toggleRef.current();
142 | };
143 |
144 | return { opened, motionX, progress, toggle };
145 | }
146 |
147 | const mobileBreakpoint = 480;
148 | const dampingOpen = 20;
149 | const dampingClose = 33;
150 | const stiffness = 400;
151 | const isMobile = () => window.innerWidth < mobileBreakpoint;
152 | const getWidth = () => (isMobile() ? window.innerWidth * 0.8 : 320);
153 |
154 | const reducer = (state: State, payload: Partial): State => ({
155 | ...state,
156 | ...payload,
157 | });
158 |
159 | const clamp = (min: number, max: number, value: number): number => {
160 | return Math.min(Math.max(value, min), max);
161 | };
162 |
--------------------------------------------------------------------------------
/src/components/Switch/Switch.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import styled from 'styled-components';
3 | import { motion, Variants } from 'framer-motion';
4 | import { rgba } from 'polished';
5 |
6 | interface Props {
7 | on?: boolean;
8 | size?: number;
9 | disabled?: boolean;
10 | focusable?: boolean;
11 | }
12 |
13 | export const Switch: React.FC = ({
14 | on = false,
15 | size = 24,
16 | disabled = false,
17 | focusable = true,
18 | }) => {
19 | const [toggled, setToggled] = useState(on);
20 |
21 | const toggler = () => setToggled(p => !p);
22 |
23 | return (
24 |
30 |
36 |
39 |
40 |
41 | );
42 | };
43 |
44 | const offFrames = [
45 | 'M42 12C42 17.5228 37.5228 22 32 22C26.4772 22 22 17.5228 22 12C22 6.47715 26.4772 2 32 2C37.5228 2 42 6.47715 42 12Z',
46 | 'M42 12C42 17.5228 37.5228 22 32 22C26.4772 22 2 19 2 12C2 5 26.4772 2 32 2C37.5228 2 42 6.47715 42 12Z',
47 | 'M16 12C16 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 16 6.47715 16 12Z',
48 | 'M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12Z',
49 | ];
50 |
51 | const onFrames = [
52 | 'M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12Z',
53 | 'M42 12C42 19 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 42 5 42 12Z',
54 | 'M42 12C42 17.5228 37.5229 22 32 22C26.4772 22 28 17.5228 28 12C28 6.47715 26.4772 2 32 2C37.5229 2 42 6.47715 42 12Z',
55 | 'M42 12C42 17.5228 37.5228 22 32 22C26.4772 22 22 17.5228 22 12C22 6.47715 26.4772 2 32 2C37.5228 2 42 6.47715 42 12Z',
56 | ];
57 |
58 | const pathVariants: Variants = {
59 | off: ({ col1 }) => ({
60 | fill: col1,
61 | d: offFrames,
62 | transition: { d: { duration: 0.5, times: [0, 0.25, 0.6, 1] } },
63 | }),
64 | on: ({ col2 }) => ({
65 | fill: col2,
66 | d: onFrames,
67 | transition: { d: { duration: 0.5, times: [0, 0.25, 0.6, 1] } },
68 | }),
69 | };
70 |
71 | const bgVariants: Variants = {
72 | off: ({ col1 }) => ({ backgroundColor: col1 }),
73 | on: ({ col2 }) => ({ backgroundColor: col2 }),
74 | };
75 |
76 | const ScSwitch = styled.button<{ $size: number }>`
77 | width: ${p => p.$size * (44 / 24)}px;
78 | height: ${p => p.$size}px;
79 | display: flex;
80 | font: inherit;
81 | padding: 0;
82 | background-color: transparent;
83 | border: none;
84 | cursor: pointer;
85 | transition: opacity 300ms ease-in-out;
86 |
87 | &:focus,
88 | &:active {
89 | outline: none;
90 | & > div {
91 | box-shadow: 0 0 0 2px ${p => p.theme.col2};
92 | transition: box-shadow 300ms ease-in-out;
93 | }
94 | }
95 | &:disabled {
96 | cursor: not-allowed;
97 | opacity: 0.2;
98 | transition: opacity 300ms ease-in-out;
99 | }
100 | @media (hover: hover) and (pointer: fine) {
101 | &:hover:not(:disabled) {
102 | & > div {
103 | box-shadow: 0 0 0 2px ${p => p.theme.col2};
104 | transition: box-shadow 300ms ease-in-out;
105 | }
106 | }
107 | }
108 | `;
109 |
110 | const ScFocus = styled(motion.div).attrs(p => ({
111 | custom: {
112 | col1: p.theme.col3,
113 | col2: p.theme.col2,
114 | },
115 | }))`
116 | width: 100%;
117 | height: 100%;
118 | display: flex;
119 | align-items: center;
120 | justify-content: center;
121 | border-radius: 999px;
122 | transition: box-shadow 300ms ease-in-out;
123 |
124 | &:focus,
125 | &:active {
126 | outline: none;
127 | }
128 | `;
129 |
130 | const ScPath = styled(motion.path).attrs(p => ({
131 | custom: {
132 | col1: rgba(p.theme.col1, 0.5),
133 | col2: rgba(p.theme.col1, 1.0),
134 | },
135 | }))``;
136 |
--------------------------------------------------------------------------------
/src/components/Switch/index.ts:
--------------------------------------------------------------------------------
1 | export * from './Switch';
2 |
--------------------------------------------------------------------------------
/src/components/Tabs/index.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useRef, useEffect } from 'react';
2 | import styled from 'styled-components';
3 | import {
4 | motion,
5 | AnimatePresence,
6 | AnimateSharedLayout,
7 | Variants,
8 | Transition,
9 | } from 'framer-motion';
10 | import { TabItem } from './types';
11 | export * from './types';
12 |
13 | interface Props {
14 | show?: boolean;
15 | tabItems: TabItem[];
16 | className?: string;
17 | blockBackground?: boolean;
18 | resetOnHide?: boolean;
19 | resetOnButtonClick?: boolean;
20 | }
21 |
22 | export const Tabs: React.FC = ({
23 | show = true,
24 | tabItems,
25 | className,
26 | blockBackground = false,
27 | resetOnHide = false,
28 | resetOnButtonClick = false,
29 | }) => {
30 | const [active, setActive] = useState(-1);
31 | const prevActive = useRef(active);
32 |
33 | const clickHandler = (item: TabItem, index: number) => () => {
34 | prevActive.current = active;
35 | if (item.isButton) {
36 | resetOnButtonClick && index > -1 && setActive(-1);
37 | (!resetOnButtonClick || active === -1) && item.onClick && item.onClick();
38 | } else {
39 | if (index === active) index > -1 && setActive(-1);
40 | else setActive(index);
41 | }
42 | };
43 |
44 | const tabs = tabItems.map((item, index) => {
45 | const Icon = item.icon;
46 | return (
47 |
52 | );
53 | });
54 |
55 | useEffect(() => {
56 | if (!resetOnHide) return;
57 | if (!show) setActive(-1);
58 | }, [show, resetOnHide]);
59 |
60 | return (
61 | <>
62 | {blockBackground && (
63 |
64 | {active > -1 && (
65 | setActive(-1)}
70 | transition={transition}
71 | />
72 | )}
73 |
74 | )}
75 |
76 |
82 |
83 |
88 |
89 | {active > -1 && (
90 |
99 | )}
100 |
101 |
102 | {tabs}
103 |
104 | >
105 | );
106 | };
107 |
108 | const transition: Transition = { type: 'spring', stiffness: 300, damping: 33 };
109 |
110 | const containerVariants: Variants = {
111 | hide: { y: '100%', opacity: 0, transition: transition },
112 | show: { y: '0%', opacity: 1, transition: transition },
113 | };
114 |
115 | const contentVariants: Variants = {
116 | hide: { opacity: 0, transition: transition },
117 | show: (prev: number) => ({
118 | opacity: 1,
119 | transition: { ...transition, delay: prev > -1 ? 0 : 0.3 },
120 | }),
121 | };
122 |
123 | const ScContainer = styled(motion.div)`
124 | position: absolute;
125 | bottom: 0;
126 | left: 0;
127 | width: 100%;
128 | display: flex;
129 | flex-direction: column;
130 | align-items: center;
131 | z-index: 20;
132 | `;
133 |
134 | const ScTabs = styled.div`
135 | width: 100%;
136 | display: grid;
137 | grid-auto-flow: column;
138 | grid-auto-columns: min-content;
139 | grid-gap: 16px;
140 | align-items: center;
141 | justify-content: center;
142 | padding: 8px;
143 | z-index: 1;
144 | position: relative;
145 | `;
146 |
147 | const ScContent = styled(motion.div)`
148 | width: 100%;
149 | z-index: 1;
150 | border-bottom: 1px solid rgba(0, 0, 0, 0.2);
151 | `;
152 |
153 | const ScBackground = styled(motion.div)`
154 | position: absolute;
155 | background-color: ${p => p.theme.col5};
156 | width: 100%;
157 | height: 100vh;
158 | top: 0;
159 | left: 0;
160 | border-top: 1px solid rgba(0, 0, 0, 0.2);
161 | `;
162 |
163 | const ScBackdrop = styled(motion.div)`
164 | position: absolute;
165 | width: 100%;
166 | height: 100%;
167 | top: 0;
168 | left: 0;
169 | background-color: rgba(0, 0, 0, 0.2);
170 | z-index: 19;
171 | `;
172 |
--------------------------------------------------------------------------------
/src/components/Tabs/types.ts:
--------------------------------------------------------------------------------
1 | interface IconProps {
2 | active: boolean;
3 | onClick: () => void;
4 | }
5 |
6 | export type TabItem = {
7 | id: string;
8 | name: string;
9 | icon: (props: IconProps) => React.ReactElement | null;
10 | content?: JSX.Element;
11 | isButton?: boolean;
12 | onClick?: () => void;
13 | };
14 |
--------------------------------------------------------------------------------
/src/configs/themes.ts:
--------------------------------------------------------------------------------
1 | export const col1 = '#eeeeee';
2 | export const col2 = '#0088aa';
3 | export const col3 = '#393e46';
4 | export const col4 = '#222831';
5 | export const col5 = '#1b2028';
6 | export const col6 = '#48B14C';
7 | export const col7 = '#FED219';
8 | export const col8 = '#F78828';
9 | export const col9 = '#FA5456';
10 |
--------------------------------------------------------------------------------
/src/containers/Home/Home.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import styled from 'styled-components';
3 | import { rgba } from 'polished';
4 | import { motion, AnimatePresence } from 'framer-motion';
5 | import { Menu } from '../../components/Menu';
6 | import { Button } from '../../components/Buttons';
7 | import { SampleHtml } from './SampleHtml';
8 | import { menuItems } from './sample_items';
9 | import { Document } from '../../assets/icons/essentials';
10 |
11 | export const Home: React.FC = () => {
12 | const [show, setShow] = useState(false);
13 |
14 | return (
15 |
16 |
17 | {show && setShow(false)} />}
18 |
19 | }
22 | onClick={() => setShow(true)}
23 | />
24 |
25 |
26 |
27 |
28 | );
29 | };
30 |
31 | const ScContainer = styled.div`
32 | width: 100%;
33 | height: 100%;
34 | position: fixed;
35 | top: 0;
36 | left: 0;
37 | display: flex;
38 | align-items: center;
39 | justify-content: center;
40 | `;
41 |
42 | const ScButton = styled(Button)`
43 | background-color: ${p => p.theme.col5};
44 | `;
45 |
46 | const ScDragable = styled(motion.div)`
47 | width: 100px;
48 | height: 100px;
49 | border-radius: 50%;
50 | position: fixed;
51 | bottom: 50px;
52 | right: 50px;
53 | display: flex;
54 | align-items: center;
55 | justify-content: center;
56 | background-color: ${props => props.theme.col5};
57 | box-shadow: 0 0 8px ${rgba('#000', 0.2)};
58 | cursor: grab;
59 | `;
60 |
--------------------------------------------------------------------------------
/src/containers/Home/SampleHtml/base.css:
--------------------------------------------------------------------------------
1 | #sample_html {
2 | background-color: var(--col4);
3 | color: var(--col1);
4 | font-family: var(--doc-font);
5 | font-size: var(--doc-font-size);
6 | -webkit-font-smoothing: antialiased;
7 | margin: 0;
8 | padding: 16px;
9 | overflow-x: hidden;
10 | background: inherit;
11 | tab-size: 4; }
12 | #sample_html a {
13 | color: var(--col2);
14 | cursor: pointer;
15 | word-break: break-all; }
16 | #sample_html a:hover, #sample_html a:active {
17 | outline: none; }
18 | #sample_html hr {
19 | height: 1px;
20 | background-color: var(--col3);
21 | border: none; }
22 | #sample_html h1,
23 | #sample_html h2,
24 | #sample_html h3,
25 | #sample_html h4,
26 | #sample_html h5,
27 | #sample_html h6 {
28 | width: inherit;
29 | page-break-after: avoid;
30 | break-after: avoid-page;
31 | page-break-inside: avoid;
32 | orphans: 4;
33 | margin: 1em 0; }
34 | #sample_html h1 {
35 | font-size: 2em; }
36 | #sample_html h2 {
37 | font-size: 1.8em; }
38 | #sample_html h3 {
39 | font-size: 1.6em; }
40 | #sample_html h4 {
41 | font-size: 1.4em; }
42 | #sample_html h5 {
43 | font-size: 1.2em; }
44 | #sample_html h6 {
45 | font-size: 1em; }
46 | #sample_html p {
47 | width: inherit;
48 | line-height: inherit;
49 | orphans: 4;
50 | margin-top: 1em;
51 | margin-bottom: 1em; }
52 | #sample_html img {
53 | margin: 0.5em 0;
54 | max-width: 100%;
55 | vertical-align: middle;
56 | image-orientation: from-image;
57 | background-color: var(--col1); }
58 | #sample_html button,
59 | #sample_html select,
60 | #sample_html textarea {
61 | color: inherit;
62 | font: inherit; }
63 | #sample_html input {
64 | color: inherit;
65 | font: inherit; }
66 | #sample_html input[type='checkbox'], #sample_html input[type='radio'] {
67 | line-height: normal;
68 | padding: 0; }
69 | #sample_html input[type='checkbox'] {
70 | cursor: pointer;
71 | width: inherit;
72 | height: inherit; }
73 | #sample_html ol,
74 | #sample_html ul {
75 | margin: 0;
76 | padding-left: 2em; }
77 | #sample_html li {
78 | margin: 0 0 1em 0; }
79 | #sample_html li:first-child {
80 | margin-top: 1em; }
81 | #sample_html li p {
82 | margin: 0 0 0.5em 0; }
83 | #sample_html li blockquote {
84 | margin: 1em 0; }
85 | #sample_html figure {
86 | max-width: 100%;
87 | overflow-x: auto;
88 | margin: 0;
89 | padding: 0; }
90 | #sample_html table {
91 | border-collapse: collapse;
92 | border-spacing: 0;
93 | width: 100%;
94 | overflow: auto;
95 | page-break-inside: auto;
96 | text-align: left; }
97 | #sample_html table tr {
98 | page-break-inside: avoid;
99 | page-break-after: auto; }
100 | #sample_html table tr:nth-child(2n) {
101 | background-color: rgba(0, 0, 0, 0.1); }
102 | #sample_html table tr th {
103 | background-color: rgba(0, 0, 0, 0.1);
104 | padding: 1em; }
105 | #sample_html table tr td {
106 | padding: 1em; }
107 | #sample_html table thead {
108 | display: table-header-group; }
109 | #sample_html dl {
110 | margin: 1em 0; }
111 | #sample_html dl dt {
112 | margin: 1em 0;
113 | font-weight: bold; }
114 | #sample_html dl dd {
115 | margin: 1em 0; }
116 | #sample_html blockquote {
117 | margin: 1em 0; }
118 | #sample_html blockquote:first-child {
119 | margin-top: 0; }
120 | #sample_html blockquote:last-child {
121 | margin-bottom: 0; }
122 | #sample_html pre {
123 | width: inherit;
124 | white-space: pre-wrap; }
125 | #sample_html pre code {
126 | display: block;
127 | font-weight: normal;
128 | color: var(--col1);
129 | background-color: var(--col5);
130 | overflow: auto;
131 | padding: 1em;
132 | white-space: pre;
133 | border-radius: 0.5em; }
134 | #sample_html code,
135 | #sample_html pre,
136 | #sample_html samp,
137 | #sample_html tt {
138 | font-family: var(--code-font);
139 | font-size: var(--code-font-size); }
140 | #sample_html code {
141 | font-weight: 300;
142 | color: var(--col2);
143 | border-radius: 0.25em;
144 | text-align: left;
145 | vertical-align: initial; }
146 | #sample_html kbd {
147 | margin: 0 0.1em;
148 | padding: 0.1em 0.6em;
149 | font-size: 0.8em;
150 | color: #242729;
151 | background: #fff;
152 | border: 1px solid #adb3b9;
153 | border-radius: 3px;
154 | box-shadow: 0 1px 0 rgba(12, 13, 14, 0.2), 0 0 0 2px #fff inset;
155 | white-space: nowrap;
156 | vertical-align: middle; }
157 | #sample_html video {
158 | max-width: 100%;
159 | display: block;
160 | margin: 0 auto; }
161 | #sample_html iframe {
162 | width: 100%;
163 | border: none;
164 | margin: auto; }
165 | #sample_html mark {
166 | background: var(--col2);
167 | color: var(--col4);
168 | padding: 0.1em 0.25em;
169 | border-radius: 0.25em; }
170 | #sample_html .footnotes {
171 | column-count: 2;
172 | font-size: 0.9em; }
173 | #sample_html .footnotes .footnotes-list {
174 | margin: 0; }
175 | #sample_html .footnotes .footnotes-list .footnote-item {
176 | margin: 0 0 1em 0; }
177 | #sample_html .highlighted {
178 | background-color: #0088aa;
179 | color: white;
180 | padding: 0.3em 0;
181 | cursor: pointer; }
182 |
--------------------------------------------------------------------------------
/src/containers/Home/SampleHtml/default.css:
--------------------------------------------------------------------------------
1 | #sample_html {
2 | line-height: 1.7;
3 | max-width: 51em;
4 | margin: auto; }
5 | #sample_html blockquote {
6 | padding: 0.5em 2em;
7 | border-left: 3px solid var(--col2);
8 | background-color: var(--col5);
9 | border-radius: 0 0.5em 0.5em 0;
10 | box-shadow: 0 0 1em rgba(0, 0, 0, 0.2);
11 | position: relative; }
12 | #sample_html blockquote::before {
13 | position: absolute;
14 | top: -8px;
15 | left: 8px;
16 | font-family: serif;
17 | content: '\201C';
18 | color: var(--col2);
19 | font-size: 3em; }
20 | #sample_html table {
21 | background-color: var(--col5);
22 | border-radius: 0.5em;
23 | overflow: hidden;
24 | box-shadow: 0 0 1em rgba(0, 0, 0, 0.2); }
25 | #sample_html h1 {
26 | text-align: center;
27 | padding-bottom: 0.3em;
28 | font-size: 2.25em;
29 | margin: 1em auto 1.2em;
30 | font-weight: 300; }
31 | #sample_html h1::after {
32 | border-bottom: 2px dashed var(--col2);
33 | content: '';
34 | width: 100px;
35 | display: block;
36 | margin: 0.2em auto 0; }
37 | #sample_html h2 {
38 | padding-left: 0.25em;
39 | border-left: 3px solid var(--col2); }
40 | #sample_html h3::before {
41 | background-color: var(--col2);
42 | content: '';
43 | width: 6px;
44 | height: 6px;
45 | border-radius: 50%;
46 | display: inline-block;
47 | vertical-align: middle;
48 | margin-bottom: 0.18em;
49 | margin-right: 0.5em; }
50 | #sample_html h4::before {
51 | background-color: var(--col2);
52 | content: '';
53 | width: 6px;
54 | height: 2px;
55 | display: inline-block;
56 | vertical-align: middle;
57 | margin-bottom: 0.18em;
58 | margin-right: 0.5em; }
59 | #sample_html h6 {
60 | color: var(--col2); }
61 | #sample_html pre code {
62 | box-shadow: 0 0 1em rgba(0, 0, 0, 0.2); }
63 | #sample_html img {
64 | border-radius: 0.5em;
65 | box-shadow: 0 0 1em rgba(0, 0, 0, 0.2); }
66 | #sample_html .footnotes-sep {
67 | display: none; }
68 | #sample_html .footnotes {
69 | background-color: var(--col5);
70 | padding: 1em 1em 1em 0;
71 | border-radius: 0.5em;
72 | box-shadow: 0 0 1em rgba(0, 0, 0, 0.2); }
73 | #sample_html ::marker {
74 | color: var(--col2); }
75 |
--------------------------------------------------------------------------------
/src/containers/Home/SampleHtml/index.tsx:
--------------------------------------------------------------------------------
1 | import { useRef, useEffect, useState } from 'react';
2 | import { createPortal } from 'react-dom';
3 | import styled from 'styled-components';
4 | import { rgba } from 'polished';
5 | import { motion } from 'framer-motion';
6 | import './base.css';
7 | import './default.css';
8 | import { sample_html } from './sample_html';
9 | import { Hightlight } from '../../../components/Hightlight';
10 | import { IconButton } from '../../../components/Buttons';
11 | import { ChevronBack } from '../../../assets/icons/essentials';
12 |
13 | interface Props {
14 | onClose: () => void;
15 | }
16 |
17 | export const SampleHtml: React.FC = ({ onClose }) => {
18 | const [html, setHtml] = useState('');
19 | const readerRef = useRef(null);
20 |
21 | const updateLocalHtml = () => {
22 | if (!readerRef.current) return;
23 | const html = readerRef.current.innerHTML;
24 | localStorage.setItem('highlighted_html', html);
25 | };
26 |
27 | useEffect(() => {
28 | const html = localStorage.getItem('highlighted_html') ?? sample_html;
29 | setHtml(html);
30 | }, []);
31 |
32 | return createPortal(
33 | <>
34 |
41 |
48 |
49 | } onClick={onClose} />
50 |
51 | Sample html taken from{' '}
52 |
53 | https://markdown-it.github.io/
54 |
55 |
56 |
57 |
58 |
59 |
64 |
65 |
66 | >,
67 | document.body
68 | );
69 | };
70 |
71 | const ScContainer = styled(motion.div)`
72 | position: fixed;
73 | top: 0;
74 | left: 0;
75 | width: 100%;
76 | height: 100%;
77 | display: grid;
78 | background-color: ${p => p.theme.col4};
79 | grid-template-rows: auto 1fr;
80 | z-index: 200;
81 | `;
82 |
83 | const ScHeading = styled.div`
84 | display: grid;
85 | grid-template-columns: auto 1fr;
86 | grid-gap: 16px;
87 | padding: 8px;
88 | align-items: center;
89 | background-color: ${p => p.theme.col5};
90 | border-bottom: 1px solid rgba(0, 0, 0, 0.2);
91 | `;
92 |
93 | const ScLabel = styled.label`
94 | width: 100%;
95 | color: ${p => rgba(p.theme.col1, 0.5)};
96 | text-align: center;
97 | `;
98 |
99 | const ScA = styled.a`
100 | color: inherit;
101 | `;
102 |
103 | const ScDocContainer = styled.div`
104 | width: 100%;
105 | height: 100%;
106 | overflow: auto;
107 | `;
108 |
109 | const ScDoc = styled.div`
110 | &::before,
111 | &::after {
112 | display: block;
113 | content: '';
114 | height: 52px;
115 | }
116 |
117 | * {
118 | user-select: text;
119 | }
120 | `;
121 |
122 | const ScBackdrop = styled(motion.div)`
123 | position: fixed;
124 | width: 100%;
125 | height: 100%;
126 | top: 0;
127 | left: 0;
128 | background: rgba(0, 0, 0, 0.8);
129 | z-index: 99;
130 | overflow: hidden;
131 | `;
132 |
--------------------------------------------------------------------------------
/src/containers/Home/SampleHtml/sample_html.ts:
--------------------------------------------------------------------------------
1 | export const sample_html = "
\nAdvertisement 😃
\n\n- pica - high quality and fast image\nresize in browser.
\n- babelfish - developer friendly\ni18n with plurals support and easy syntax.
\n
\nYou will like those projects!
\n
\nh1 Heading 😎
\nh2 Heading
\nh3 Heading
\nh4 Heading
\nh5 Heading
\nh6 Heading
\nHorizontal Rules
\n
\n
\n
\nTypographic replacements
\nEnable typographer option to see result.
\n© © ® ® ™ ™ § § ±
\ntest… test… test… test?.. test!..
\n!!! ??? , – —
\n“Smartypants, double quotes” and ‘single quotes’
\nEmphasis
\nThis is bold text
\nThis is bold text
\nThis is italic text
\nThis is italic text
\nStrikethrough
\nBlockquotes
\n\nBlockquotes can also be nested…
\n\n…by using additional greater-than signs right next to each other…
\n\n…or with spaces between arrows.
\n
\n
\n
\nLists
\nUnordered
\n\n- Create a list by starting a line with
+
, -
, or *
\n- Sub-lists are made by indenting 2 spaces:\n
\n- Marker character change forces new list start:\n
\n- Ac tristique libero volutpat at
\n
\n\n- Facilisis in pretium nisl aliquet
\n
\n\n- Nulla volutpat aliquam velit
\n
\n \n
\n \n- Very easy!
\n
\nOrdered
\n\n- \n
Lorem ipsum dolor sit amet
\n \n- \n
Consectetur adipiscing elit
\n \n- \n
Integer molestie lorem at massa
\n \n- \n
You can use sequential numbers…
\n \n- \n
…or keep all the numbers as 1.
\n \n
\nStart numbering with offset:
\n\n- foo
\n- bar
\n
\nCode
\nInline code
\nIndented code
\n// Some comments\nline 1 of code\nline 2 of code\nline 3 of code\n
\nBlock code “fences”
\nSample text here...\n
\nSyntax highlighting
\nvar foo = function (bar) {\n return bar++;\n};\n\nconsole.log(foo(5));\n
\nTables
\n\n\n\nOption | \nDescription | \n
\n\n\n\ndata | \npath to data files to supply the data that will be passed into templates. | \n
\n\nengine | \nengine to be used for processing templates. Handlebars is the default. | \n
\n\next | \nextension to be used for dest files. | \n
\n\n
\nRight aligned columns
\n\n\n\nOption | \nDescription | \n
\n\n\n\ndata | \npath to data files to supply the data that will be passed into templates. | \n
\n\nengine | \nengine to be used for processing templates. Handlebars is the default. | \n
\n\next | \nextension to be used for dest files. | \n
\n\n
\nLinks
\nlink text
\nlink with title
\nAutoconverted link https://github.com/nodeca/pica (enable linkify to see)
\nImages
\n
\n
\nLike links, Images also have a footnote style syntax
\n
\nWith a reference later in the document defining the URL location:
\nPlugins
\nThe killer feature of markdown-it
is very effective support of\nsyntax plugins.
\n\n\nClassic markup: 😉 :crush: 😢 :tear: 😆 😋
\nShortcuts (emoticons): 😃 😦 😎 😉
\n
\nsee how to change output with twemoji.
\n\n\n\nInserted text
\n\nMarked text
\n\nFootnote 1 link.
\nFootnote 2 link.
\nInline footnote definition.
\nDuplicated footnote reference.
\n\n\n- Term 1
\n- \n
Definition 1\nwith lazy continuation.
\n \n- Term 2 with inline markup
\n- \n
Definition 2
\n { some code, part of Definition 2 }\n
\nThird paragraph of definition 2.
\n \n
\nCompact style:
\n\n- Term 1
\n- Definition 1
\n- Term 2
\n- Definition 2a
\n- Definition 2b
\n
\n\nThis is HTML abbreviation example.
\nIt converts “HTML”, but keep intact partial entries like “xxxHTMLyyy” and so on.
\n\n\n\n";
--------------------------------------------------------------------------------
/src/containers/Home/sample_items.tsx:
--------------------------------------------------------------------------------
1 | import { v4 as uuid } from 'uuid';
2 | import { MenuItem } from '../../components/Menu';
3 | import {
4 | CloudUpload,
5 | Share,
6 | Search,
7 | Bookmarks,
8 | Cloud,
9 | Home,
10 | Heart,
11 | Document,
12 | Download,
13 | List,
14 | Text,
15 | Time,
16 | Expand,
17 | } from '../../assets/icons/essentials';
18 |
19 | export const menuItems: MenuItem[] = [
20 | {
21 | id: uuid(),
22 | name: 'Upload file',
23 | icon: ,
24 | },
25 | {
26 | id: uuid(),
27 | name: 'Share with others',
28 | icon: ,
29 | },
30 | // {
31 | // id: uuid(),
32 | // name: 'Magnify',
33 | // icon: ,
34 | // onClick: () => console.log('magnify'),
35 | // },
36 | {
37 | id: uuid(),
38 | name: 'Bookmark this page',
39 | icon: ,
40 | },
41 | {
42 | id: uuid(),
43 | name: 'Cloud',
44 | icon: ,
45 | disabled: true,
46 | onClick: () => console.log('cloud'),
47 | },
48 | // {
49 | // id: uuid(),
50 | // name: 'Back to home',
51 | // icon: ,
52 | // },
53 | {
54 | id: uuid(),
55 | name: 'Sort by',
56 | isSubMenu: true,
57 | icon:
,
58 | items: [
59 | {
60 | id: uuid(),
61 | name: 'File name',
62 | icon: ,
63 | },
64 | {
65 | id: uuid(),
66 | name: 'Date created',
67 | icon: ,
68 | },
69 | {
70 | id: uuid(),
71 | name: 'Date Modified',
72 | icon: ,
73 | },
74 | ],
75 | },
76 | // {
77 | // id: uuid(),
78 | // name: 'Fevorite',
79 | // icon: ,
80 | // },
81 | {
82 | id: uuid(),
83 | name: 'Download PDF',
84 | icon: ,
85 | disabled: true,
86 | },
87 | // {
88 | // id: uuid(),
89 | // name: 'Download this file',
90 | // icon: ,
91 | // },
92 | {
93 | id: uuid(),
94 | name: 'Deep submenu',
95 | isSubMenu: true,
96 | icon: ,
97 | items: [
98 | {
99 | id: uuid(),
100 | name: 'Option',
101 | icon: ,
102 | },
103 | {
104 | id: uuid(),
105 | name: 'Another option',
106 | icon: ,
107 | },
108 | {
109 | id: uuid(),
110 | name: 'Deep',
111 | icon: ,
112 | isSubMenu: true,
113 | items: [
114 | {
115 | id: uuid(),
116 | name: 'Deep option 1',
117 | icon: ,
118 | },
119 | {
120 | id: uuid(),
121 | name: 'Deep option 2',
122 | icon: ,
123 | },
124 | {
125 | id: uuid(),
126 | name: 'Deep option 3',
127 | icon: ,
128 | },
129 | ],
130 | },
131 | ],
132 | },
133 | ];
134 |
--------------------------------------------------------------------------------
/src/containers/TestComponent/TestComponent.tsx:
--------------------------------------------------------------------------------
1 | export const TestComponent: React.FC = () => {
2 | return null;
3 | };
4 |
--------------------------------------------------------------------------------
/src/hooks/index.ts:
--------------------------------------------------------------------------------
1 | export * from './useDimensions';
2 | export * from './usePosition';
3 | export * from './useWindowResize';
4 | export * from './useHighlightRange';
5 |
--------------------------------------------------------------------------------
/src/hooks/useDimensions.ts:
--------------------------------------------------------------------------------
1 | import { useLayoutEffect } from 'react';
2 |
3 | export interface DimensionData {
4 | x: number;
5 | y: number;
6 | width: number;
7 | height: number;
8 | }
9 |
10 | const useDimensions = (
11 | ref: React.MutableRefObject,
12 | callback: (data: DimensionData | null) => void = () => {}
13 | ) => {
14 | useLayoutEffect(() => {
15 | if (ref.current) {
16 | const { offsetWidth: width, offsetHeight: height } = ref.current;
17 | const { x, y } = ref.current.getBoundingClientRect();
18 | callback({ x, y, width, height });
19 | } else callback(null);
20 | }, [ref, callback]);
21 | };
22 |
23 | export { useDimensions };
24 |
--------------------------------------------------------------------------------
/src/hooks/useHighlightRange.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from 'react';
2 | import { HighlightRange } from '../libs/highlight-range';
3 | import { convertStyle } from './utils';
4 | import * as CSS from 'csstype';
5 |
6 | const hr = HighlightRange();
7 |
8 | export interface HighlightRangeConfigs {
9 | readerRef: React.MutableRefObject;
10 | tagName?: string;
11 | attributes?: { [key: string]: string };
12 | styles?: CSS.Properties;
13 | stateChangeCallback: (info: {
14 | selecting?: boolean;
15 | selectedId?: string | null;
16 | selectedStyles?: CSS.Properties | null;
17 | selectedNote?: string | null;
18 | }) => void;
19 | }
20 |
21 | export const useHighlightRange = ({
22 | readerRef,
23 | tagName = 'span',
24 | attributes = {},
25 | styles = {},
26 | stateChangeCallback,
27 | }: HighlightRangeConfigs) => {
28 | const selectingRef = useRef(false);
29 | const selectedIdRef = useRef(null);
30 |
31 | const cancel = () => {
32 | stateChangeCallback({
33 | selecting: (selectingRef.current = false),
34 | selectedId: (selectedIdRef.current = null),
35 | selectedStyles: null,
36 | selectedNote: null,
37 | });
38 | };
39 |
40 | const updateStyle = (newStyles: CSS.Properties) => {
41 | if (!readerRef.current || !selectedIdRef.current) return;
42 | const selector = `[highlightid="${selectedIdRef.current}"]`;
43 | const nodes = readerRef.current.querySelectorAll(selector);
44 | const cssText = convertStyle('toCss', newStyles);
45 | for (let i = 0; i < nodes.length; i++) {
46 | const node = nodes[i] as HTMLElement;
47 | node.style.cssText = cssText;
48 | }
49 | };
50 |
51 | const updateNote = (note: string) => {
52 | if (!readerRef.current || !selectedIdRef.current) return;
53 | const selector = `[highlightid="${selectedIdRef.current}"]`;
54 | const nodes = readerRef.current.querySelectorAll(selector);
55 | for (let i = 0; i < nodes.length; i++) {
56 | const node = nodes[i] as HTMLElement;
57 | node.setAttribute('highlightnote', note);
58 | }
59 | };
60 |
61 | const highlight = () => {
62 | const selection = window.getSelection();
63 | if (!selection || selection.isCollapsed) return;
64 | const range = selection.getRangeAt(0);
65 | const attrs = { ...attributes, style: convertStyle('toCss', styles) };
66 | const highlightId = hr.makeHighlight(range, tagName, attrs);
67 | if (!highlightId) return;
68 | stateChangeCallback({
69 | selecting: (selectingRef.current = false),
70 | selectedId: (selectedIdRef.current = highlightId),
71 | });
72 | selection.removeAllRanges();
73 | };
74 |
75 | const removeHighlight = () => {
76 | if (!selectedIdRef.current) return;
77 | hr.removeHighlight(selectedIdRef.current);
78 | stateChangeCallback({
79 | selecting: (selectingRef.current = false),
80 | selectedId: (selectedIdRef.current = null),
81 | selectedStyles: null,
82 | selectedNote: null,
83 | });
84 | };
85 |
86 | useEffect(() => {
87 | if (!readerRef.current) return;
88 | const element = readerRef.current;
89 |
90 | const selectionHandler = () => {
91 | const selection = window.getSelection();
92 | if (selectingRef.current || !selection || selection.isCollapsed) return;
93 | stateChangeCallback({ selecting: (selectingRef.current = true) });
94 | };
95 |
96 | const clickHandler = (event: Event) => {
97 | const target = event.target as HTMLElement;
98 | if (target && target.hasAttribute('highlighted')) {
99 | const highlightId = target.getAttribute('highlightid');
100 | if (!highlightId || selectedIdRef.current === highlightId) return;
101 | const css = target.getAttribute('style');
102 | const styleObject = css ? convertStyle('toObject', css) : null;
103 | const note = target.getAttribute('highlightnote');
104 | stateChangeCallback({
105 | selectedId: (selectedIdRef.current = highlightId),
106 | selectedStyles: styleObject,
107 | selectedNote: note,
108 | });
109 | } else {
110 | const selection = window.getSelection();
111 | selection &&
112 | selection.isCollapsed &&
113 | (selectingRef.current || selectedIdRef.current) &&
114 | stateChangeCallback({
115 | selecting: (selectingRef.current = false),
116 | selectedId: (selectedIdRef.current = null),
117 | selectedStyles: null,
118 | selectedNote: null,
119 | });
120 | }
121 | };
122 |
123 | document.addEventListener('selectionchange', selectionHandler);
124 | element.addEventListener('click', clickHandler);
125 |
126 | return () => {
127 | document.removeEventListener('selectionchange', selectionHandler);
128 | element.removeEventListener('click', clickHandler);
129 | };
130 | }, [readerRef, stateChangeCallback]);
131 |
132 | return {
133 | cancel,
134 | highlight,
135 | removeHighlight,
136 | updateStyle,
137 | updateNote,
138 | };
139 | };
140 |
--------------------------------------------------------------------------------
/src/hooks/usePosition.ts:
--------------------------------------------------------------------------------
1 | import { useLayoutEffect, useRef } from 'react';
2 | import { useDimensions, DimensionData } from './useDimensions';
3 |
4 | export interface PositionData {
5 | top?: number;
6 | bottom?: number;
7 | left?: number;
8 | right?: number;
9 | transformOriginX: 'left' | 'right';
10 | transformOriginY: 'top' | 'bottom';
11 | }
12 |
13 | const usePosition = (
14 | ref_0: React.MutableRefObject,
15 | ref_1: React.MutableRefObject,
16 | callback: (data: PositionData | null) => void = () => {},
17 | margin: number = 16
18 | ) => {
19 | const dimension_0 = useRef(null);
20 | const dimension_1 = useRef(null);
21 |
22 | useDimensions(ref_0, data => (dimension_0.current = data));
23 | useDimensions(ref_1, data => (dimension_1.current = data));
24 |
25 | useLayoutEffect(() => {
26 | if (dimension_0.current && dimension_1.current) {
27 | const { width: tw, height: th, x: tx, y: ty } = dimension_0.current;
28 | const { width: iw, height: ih } = dimension_1.current;
29 | const { innerWidth: pw, innerHeight: ph } = window;
30 |
31 | const leftSpace = tx - margin;
32 | const rightSpace = pw - tx - tw - margin;
33 | const upSpace = ty - margin;
34 | const downSpace = ph - ty - th - margin;
35 |
36 | let transformOriginX: 'left' | 'right' = 'right';
37 | let transformOriginY: 'top' | 'bottom' = 'top';
38 | let top, bottom, left, right;
39 |
40 | // default placing position left bottom
41 |
42 | if (rightSpace > leftSpace && iw > leftSpace) {
43 | // plcing in right
44 | left = tx + tw < margin ? margin : tx + tw;
45 | if (left + iw > pw - margin) left = pw - iw - margin;
46 | transformOriginX = 'left';
47 | } else {
48 | // placing in left
49 | right = pw - tx < margin ? margin : pw - tx;
50 | if (pw - right - margin < iw) right = pw - iw - margin;
51 | }
52 |
53 | if (upSpace > downSpace && ih > downSpace) {
54 | // placing in top
55 | bottom = ph - ty < margin ? margin : ph - ty;
56 | if (ph - bottom - margin < ih) bottom = ph - ih - margin;
57 | transformOriginY = 'bottom';
58 | } else {
59 | // placing in bottom
60 | top = ty + th < margin ? margin : ty + th;
61 | if (top + ih > ph - margin) top = ph - ih - margin;
62 | }
63 |
64 | callback({
65 | transformOriginX,
66 | transformOriginY,
67 | left,
68 | right,
69 | top,
70 | bottom,
71 | });
72 | } else callback(null);
73 | }, [dimension_0, dimension_1, margin, callback]);
74 | };
75 |
76 | export { usePosition };
77 |
--------------------------------------------------------------------------------
/src/hooks/useWindowResize.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useCallback } from 'react';
2 |
3 | export const useWindowResize = (
4 | callback: () => void = () => {},
5 | interval: number = 100
6 | ) => {
7 | const resizeTimeout = useRef(null);
8 |
9 | const resizeHandler = useCallback(() => {
10 | if (resizeTimeout.current != null) clearTimeout(resizeTimeout.current);
11 | resizeTimeout.current = setTimeout(() => {
12 | resizeTimeout.current = null;
13 | callback();
14 | }, interval);
15 | }, [interval, callback]);
16 |
17 | useEffect(() => {
18 | window.addEventListener('resize', resizeHandler);
19 | return () => {
20 | if (resizeTimeout.current != null) clearTimeout(resizeTimeout.current);
21 | window.removeEventListener('resize', resizeHandler);
22 | };
23 | }, [resizeHandler]);
24 | };
25 |
--------------------------------------------------------------------------------
/src/hooks/utils.ts:
--------------------------------------------------------------------------------
1 | import * as CSS from 'csstype';
2 | import objectToCss from 'react-style-object-to-css';
3 | import cssToObject from 'style-to-js';
4 |
5 | export function convertStyle(
6 | mode: 'toCss',
7 | styles: CSS.Properties
8 | ): string;
9 |
10 | export function convertStyle(
11 | mode: 'toObject',
12 | styles: string
13 | ): CSS.Properties;
14 |
15 | export function convertStyle(
16 | mode: 'toCss' | 'toObject',
17 | styles: CSS.Properties | string
18 | ): CSS.Properties | string {
19 | if (mode === 'toCss') return objectToCss(styles);
20 | else return cssToObject(styles as string);
21 | }
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | /* Roboto */
2 | @import url('https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap');
3 | /* Roboto Mono*/
4 | @import url('https://fonts.googleapis.com/css2?family=Roboto+Mono:ital,wght@0,300;0,400;0,500;0,600;0,700;1,300;1,400;1,500;1,600;1,700&display=swap');
5 |
6 | :root {
7 | --col1: #888888;
8 | --col2: #0088aa;
9 | --col3: #393e46;
10 | --col4: #222831;
11 | --col5: #1b2028;
12 | --col6: #48b14c;
13 | --col7: #fed219;
14 | --col8: #f78828;
15 | --col9: #fa5456;
16 |
17 | --doc-font: sans-serif;
18 | --doc-font-size: 14px;
19 | --code-font: monospace;
20 | --code-font-size: 14px;
21 | --monospace: 'Roboto Mono', monospace;
22 | }
23 |
24 | html {
25 | background-color: #222831;
26 | }
27 |
28 | body {
29 | margin: 0;
30 | font-family: 'Roboto', sans-serif;
31 | -webkit-font-smoothing: antialiased;
32 | -moz-osx-font-smoothing: grayscale;
33 | overscroll-behavior: none;
34 | }
35 |
36 | *,
37 | ::after,
38 | ::before {
39 | box-sizing: border-box;
40 | user-select: none;
41 | -webkit-tap-highlight-color: transparent;
42 | }
43 |
44 | @media (min-width: 480px) {
45 | ::-webkit-scrollbar {
46 | width: 8px;
47 | height: 8px;
48 | }
49 |
50 | ::-webkit-scrollbar-thumb {
51 | background: rgba(255, 255, 255, 0.1);
52 | border-radius: 4px;
53 | }
54 |
55 | ::-webkit-scrollbar-track {
56 | background: none;
57 | }
58 | }
59 |
60 | ::selection {
61 | background-color: var(--col1);
62 | color: var(--col4);
63 | }
64 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import { StrictMode } from 'react';
2 | import ReactDOM from 'react-dom';
3 | import './index.css';
4 | import { App } from './App';
5 | import reportWebVitals from './reportWebVitals';
6 | import * as theme from './configs/themes';
7 | import { ThemeProvider } from 'styled-components';
8 |
9 | const app = (
10 |
11 |
12 |
13 |
14 |
15 | );
16 |
17 | ReactDOM.render(app, document.getElementById('root'));
18 |
19 | // If you want to start measuring performance in your app, pass a function
20 | // to log results (for example: reportWebVitals(console.log))
21 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
22 | reportWebVitals();
23 |
--------------------------------------------------------------------------------
/src/libs/highlight-range/index.ts:
--------------------------------------------------------------------------------
1 | //ref https://github.com/Treora/dom-highlight-range/blob/master/highlight-range.js
2 |
3 | import { v4 as uuid } from 'uuid';
4 |
5 | export type HighlightRangeI = ReturnType;
6 |
7 | export function HighlightRange() {
8 | function makeHighlight(
9 | range: Range,
10 | tagName?: string,
11 | attributes?: { [key: string]: string }
12 | ): string | null {
13 | if (range.collapsed) return null;
14 |
15 | // id for removing highlight
16 | const id = uuid();
17 | const newAttributes = { ...attributes, highlightid: id, highlighted: '' };
18 |
19 | // First put all nodes in an array (splits start and end nodes if needed)
20 | const nodesToHighlight = nodesInRange(range, filterTextNodes);
21 |
22 | // Highlight each node
23 | for (let i = 0; i < nodesToHighlight.length; i++) {
24 | const highlightedNode = wrapNodeInHighlight(
25 | nodesToHighlight[i],
26 | tagName,
27 | newAttributes
28 | );
29 |
30 | // set range start to new node
31 | if (i === 0) range.setStart(highlightedNode, 0);
32 | // set range end to new node
33 | if (i === nodesToHighlight.length - 1) range.setEnd(highlightedNode, 1);
34 | }
35 |
36 | return id;
37 | }
38 |
39 | function removeHighlight(
40 | highlightid: string,
41 | rootElement: Document | HTMLElement = document
42 | ) {
43 | const highlightedNodes = rootElement.querySelectorAll(
44 | `[highlightid="${highlightid}"]`
45 | );
46 |
47 | for (let i = 0; i < highlightedNodes.length; i++) {
48 | const node = highlightedNodes[i];
49 | const parent = node.parentNode;
50 | if (node.childNodes.length === 1)
51 | parent?.replaceChild(node.firstChild as Text, node);
52 | else {
53 | while (node.firstChild)
54 | parent?.insertBefore(node.firstChild as Text, node);
55 | }
56 | if (i === 0) parent?.normalize();
57 | if (i === highlightedNodes.length - 1) parent?.normalize();
58 | }
59 | }
60 |
61 | return { makeHighlight, removeHighlight };
62 | }
63 |
64 | // Return an array of the text nodes in the range. Split the start and end nodes if required.
65 | function nodesInRange(
66 | range: Range,
67 | filter?: (node: Node | null) => Node | null
68 | ): Node[] {
69 | if (
70 | range.startContainer.nodeType === Node.TEXT_NODE &&
71 | range.startOffset > 0 &&
72 | range.startOffset < (range.startContainer as Text).length
73 | ) {
74 | // (this may get lost when the splitting the node)
75 | const endOffset = range.endOffset;
76 | const createdNode = (range.startContainer as Text).splitText(
77 | range.startOffset
78 | );
79 |
80 | // If the end was in the same container,
81 | // it will now be in the newly created node.
82 | if (range.endContainer === range.startContainer)
83 | range.setEnd(createdNode, endOffset - range.startOffset);
84 | range.setStart(createdNode, 0);
85 | }
86 |
87 | if (
88 | range.endContainer.nodeType === Node.TEXT_NODE &&
89 | range.endOffset > 0 &&
90 | range.endOffset < (range.endContainer as Text).length
91 | )
92 | (range.endContainer as Text).splitText(range.endOffset);
93 |
94 | // Collect the text nodes.
95 | const owner = range.startContainer.ownerDocument ?? document;
96 | const walker = owner.createTreeWalker(
97 | range.commonAncestorContainer,
98 | NodeFilter.SHOW_ALL
99 | );
100 | walker.currentNode = range.startContainer;
101 |
102 | let reachedEnd = range.endContainer.contains(walker.currentNode);
103 | const skipStartContainer =
104 | range.startContainer.nodeType === Node.TEXT_NODE
105 | ? range.startOffset === (range.startContainer as Text).length
106 | : range.startOffset === range.startContainer.cloneNode.length;
107 | const skipEndContainer = range.endOffset === 0;
108 | const nodes: Node[] = [];
109 |
110 | if (walker.currentNode.nodeType === Node.TEXT_NODE && !skipStartContainer)
111 | nodes.push(walker.currentNode);
112 | while (walker.nextNode()) {
113 | const isInsideEnd = range.endContainer.contains(walker.currentNode);
114 | if (!reachedEnd && isInsideEnd) reachedEnd = true;
115 | if (reachedEnd && (skipEndContainer || !isInsideEnd)) break;
116 | if (skipStartContainer && range.startContainer.contains(walker.currentNode))
117 | continue;
118 | const filteredNode = filter
119 | ? filter(walker.currentNode)
120 | : walker.currentNode;
121 | filteredNode && nodes.push(walker.currentNode);
122 | }
123 |
124 | return nodes;
125 | }
126 |
127 | // Replace [node] with [node]
128 | function wrapNodeInHighlight(
129 | node: Node,
130 | tagName: string = 'mark',
131 | attributes?: { [key: string]: string }
132 | ) {
133 | const highlightElement = document.createElement(tagName);
134 |
135 | if (attributes) {
136 | for (const key in attributes) {
137 | highlightElement.setAttribute(key, attributes[key]);
138 | }
139 | }
140 |
141 | const owner = node.ownerDocument ?? document;
142 | const tempRange = owner.createRange();
143 | tempRange.selectNode(node);
144 | tempRange.surroundContents(highlightElement);
145 | return highlightElement;
146 | }
147 |
148 | // returns textNode with content or null
149 | function filterTextNodes(node: Node | null): Node | null {
150 | if (
151 | node &&
152 | node.nodeType === Node.TEXT_NODE &&
153 | node.textContent &&
154 | !/^\n$/.test(node.textContent) // filter newline
155 | )
156 | return node;
157 | else return null;
158 | }
159 |
--------------------------------------------------------------------------------
/src/libs/pan/index.ts:
--------------------------------------------------------------------------------
1 | export * from './pan';
2 | export * from './types';
3 |
--------------------------------------------------------------------------------
/src/libs/pan/pan.ts:
--------------------------------------------------------------------------------
1 | import {
2 | PanDirections,
3 | Directions,
4 | StartPosition,
5 | CurrentPosition,
6 | Offset,
7 | Delta,
8 | Velocity,
9 | PanConfigs,
10 | PanInterface,
11 | } from './types';
12 |
13 | export function Pan(target?: HTMLElement | Document | null): PanInterface {
14 | const targetElement = target ?? document;
15 |
16 | // configs
17 | let { panDirection, onPanStart, onPanMove, onPanEnd }: PanConfigs = {};
18 |
19 | // states
20 | let added = false;
21 | let panning = false;
22 | let offset: Offset = { ox: 0, oy: 0 };
23 | let startPosition: StartPosition = { sx: 0, sy: 0 };
24 | let prevPosition: CurrentPosition = { x: 0, y: 0, time: 0 };
25 | let positions: CurrentPosition[] = [];
26 | let velocity: Velocity = { vx: 0, vy: 0 };
27 |
28 | function startfunc(event: Event | TouchEvent | MouseEvent): void {
29 | if (event.type === 'mousedown' && (event as MouseEvent).button !== 0)
30 | return;
31 | offset = getTargetOffset(targetElement);
32 | const position = getCurrentPosition(event as TouchEvent | MouseEvent);
33 |
34 | if (
35 | onPanStart &&
36 | onPanStart({
37 | start: { sx: position.x, sy: position.y },
38 | offset,
39 | }) === false
40 | )
41 | return;
42 |
43 | startPosition = { sx: position.x, sy: position.y };
44 | prevPosition = position;
45 | positions.push(position);
46 |
47 | document.addEventListener('mousemove', movefunc);
48 | document.addEventListener('mouseup', endfunc);
49 | document.addEventListener('touchmove', movefunc, { passive: false });
50 | document.addEventListener('touchend', endfunc);
51 | document.addEventListener('mouseleave', endfunc);
52 | }
53 |
54 | function movefunc(event: TouchEvent | MouseEvent): void {
55 | const position = getCurrentPosition(event);
56 | const delta = getDelta(prevPosition, position);
57 |
58 | if (panning || testDirection(delta, panDirection)) {
59 | event.cancelable && event.preventDefault();
60 | panning = true;
61 | positions.push(position);
62 | velocity = getVelocity(delta);
63 | prevPosition = position;
64 |
65 | onPanMove &&
66 | onPanMove({
67 | start: startPosition,
68 | current: position,
69 | offset,
70 | delta,
71 | velocity,
72 | });
73 | //
74 | } else {
75 | document.removeEventListener('mousemove', movefunc);
76 | document.removeEventListener('touchmove', movefunc);
77 | }
78 | }
79 |
80 | function endfunc(): void {
81 | if (panning) {
82 | const lastPostion = positions[positions.length - 5] ?? positions[0];
83 | const delta = getDelta(lastPostion, prevPosition);
84 | const { direction, angle } = getDirection(delta);
85 |
86 | onPanEnd &&
87 | onPanEnd({
88 | start: startPosition,
89 | current: prevPosition,
90 | offset,
91 | velocity,
92 | angle,
93 | direction,
94 | });
95 | //
96 | }
97 |
98 | document.removeEventListener('mousemove', movefunc);
99 | document.removeEventListener('mouseup', endfunc);
100 | document.removeEventListener('touchmove', movefunc);
101 | document.removeEventListener('touchend', endfunc);
102 | document.removeEventListener('mouseleave', endfunc);
103 |
104 | panning = false;
105 | offset = { ox: 0, oy: 0 };
106 | startPosition = { sx: 0, sy: 0 };
107 | prevPosition = { x: 0, y: 0, time: 0 };
108 | velocity = { vx: 0, vy: 0 };
109 | positions = [];
110 | }
111 |
112 | const pan: PanInterface = {
113 | add: function (configs: PanConfigs = {} as PanConfigs): PanInterface {
114 | if (added) return pan;
115 | added = true;
116 | ({ panDirection, onPanStart, onPanEnd, onPanMove } = configs);
117 | targetElement.addEventListener('mousedown', startfunc);
118 | targetElement.addEventListener('touchstart', startfunc);
119 | return pan;
120 | },
121 |
122 | update: function (newConfigs: Partial): PanInterface {
123 | if (!added) return pan;
124 | ({
125 | panDirection = panDirection,
126 | onPanStart = onPanStart,
127 | onPanEnd = onPanEnd,
128 | onPanMove = onPanMove,
129 | } = newConfigs);
130 |
131 | return pan;
132 | },
133 |
134 | remove: function (): void {
135 | if (!added) return;
136 | added = false;
137 | ({ panDirection, onPanStart, onPanEnd, onPanMove } = {} as PanConfigs);
138 | targetElement.removeEventListener('mousedown', startfunc);
139 | targetElement.removeEventListener('touchstart', startfunc);
140 | document.removeEventListener('mousemove', movefunc);
141 | document.removeEventListener('mouseup', endfunc);
142 | document.removeEventListener('mouseleave', endfunc);
143 | document.removeEventListener('touchmove', movefunc);
144 | document.removeEventListener('touchend', endfunc);
145 | },
146 | };
147 |
148 | return pan;
149 | }
150 |
151 | function getTargetOffset(target: HTMLElement | Document): Offset {
152 | const targetPosition =
153 | 'getBoundingClientRect' in target
154 | ? target.getBoundingClientRect()
155 | : { x: 0, y: 0 };
156 | return { ox: targetPosition.x, oy: targetPosition.y };
157 | }
158 |
159 | function getCurrentPosition(event: TouchEvent | MouseEvent): CurrentPosition {
160 | if ('changedTouches' in event) {
161 | return {
162 | x: event.changedTouches[0].clientX,
163 | y: event.changedTouches[0].clientY,
164 | time: event.timeStamp,
165 | };
166 | } else {
167 | return {
168 | x: event.clientX,
169 | y: event.clientY,
170 | time: event.timeStamp,
171 | };
172 | }
173 | }
174 |
175 | function getDirection(delta: Delta): { direction: Directions; angle: number } {
176 | const angle = Math.atan2(delta.dy, delta.dx);
177 |
178 | let direction: PanDirections = PanDirections.left;
179 | if (angle > -0.75 * Math.PI) direction = PanDirections.top;
180 | if (angle > -0.25 * Math.PI) direction = PanDirections.right;
181 | if (angle > 0.25 * Math.PI) direction = PanDirections.bottom;
182 | if (angle > 0.75 * Math.PI) direction = PanDirections.left;
183 |
184 | return { direction, angle };
185 | }
186 |
187 | function getDelta(
188 | startPosition: CurrentPosition,
189 | endPosition: CurrentPosition
190 | ): Delta {
191 | return {
192 | dx: endPosition.x - startPosition.x,
193 | dy: endPosition.y - startPosition.y,
194 | dt: endPosition.time - startPosition.time,
195 | };
196 | }
197 |
198 | function getVelocity(delta: Delta): Velocity {
199 | if (delta.dt === 0) return { vx: 0, vy: 0 };
200 | else return { vx: delta.dx / delta.dt, vy: delta.dy / delta.dt };
201 | }
202 |
203 | function testDirection(
204 | delta: Delta,
205 | panDirection: PanDirections = PanDirections.any
206 | ): boolean {
207 | if (panDirection === PanDirections.any) return true;
208 | const { direction } = getDirection(delta);
209 |
210 | return (
211 | panDirection === direction ||
212 | (panDirection === PanDirections.horizontal &&
213 | (direction === PanDirections.right ||
214 | direction === PanDirections.left)) ||
215 | (panDirection === PanDirections.vertical &&
216 | (direction === PanDirections.top || direction === PanDirections.bottom))
217 | );
218 | }
219 |
--------------------------------------------------------------------------------
/src/libs/pan/types.ts:
--------------------------------------------------------------------------------
1 | export enum PanDirections {
2 | top,
3 | right,
4 | bottom,
5 | left,
6 | horizontal,
7 | vertical,
8 | any,
9 | }
10 |
11 | export type Directions = Exclude<
12 | PanDirections,
13 | PanDirections.horizontal | PanDirections.vertical | PanDirections.any
14 | >;
15 |
16 | export type StartPosition = { sx: number; sy: number };
17 | export type CurrentPosition = { x: number; y: number; time: number };
18 | export type Offset = { ox: number; oy: number };
19 | export type Delta = { dx: number; dy: number; dt: number };
20 | export type Velocity = { vx: number; vy: number };
21 |
22 | export type PanStartInfo = {
23 | start: StartPosition;
24 | offset: Offset;
25 | };
26 |
27 | export type PanMoveInfo = {
28 | start: StartPosition;
29 | current: CurrentPosition;
30 | offset: Offset;
31 | delta: Delta;
32 | velocity: Velocity;
33 | };
34 |
35 | export type PanEndInfo = {
36 | start: StartPosition;
37 | current: CurrentPosition;
38 | offset: Offset;
39 | velocity: Velocity;
40 | direction: Directions;
41 | angle: number;
42 | };
43 |
44 | export interface PanConfigs {
45 | panDirection?: PanDirections;
46 | onPanStart?: (info: PanStartInfo) => boolean | void;
47 | onPanMove?: (info: PanMoveInfo) => void;
48 | onPanEnd?: (info: PanEndInfo) => void;
49 | }
50 |
51 | export type PanInterface = {
52 | add(configs?: PanConfigs): PanInterface;
53 | update(configs: Partial): PanInterface;
54 | remove(): void;
55 | };
56 |
--------------------------------------------------------------------------------
/src/libs/spring/index.ts:
--------------------------------------------------------------------------------
1 | export * from './spring';
2 | export * from './types';
--------------------------------------------------------------------------------
/src/libs/spring/spring.ts:
--------------------------------------------------------------------------------
1 | import { SpringConfigs, SpringInterface } from './types';
2 |
3 | export function Spring(configs: SpringConfigs = {}): SpringInterface {
4 | let {
5 | from = 0,
6 | to = 0,
7 | stiffness = 100,
8 | damping = 10,
9 | initialVelocity = 0,
10 | mass = 1,
11 | restDisplacement = 0.1,
12 | restVelocity = 0.1,
13 | onUpdate,
14 | onComplete,
15 | } = configs;
16 |
17 | let totalDisplacement = to - from;
18 | let zeta = damping / (2 * Math.sqrt(stiffness * mass));
19 | let omega = Math.sqrt(stiffness / mass) / 1000;
20 | let omega_d = omega * Math.sqrt(1.0 - zeta * zeta);
21 |
22 | let currentTime = 0;
23 | let prevTime = 0;
24 | let currentPosition = from;
25 | let prevPosition = 0;
26 | let currentVelocity = 0;
27 |
28 | let isAnimating = false;
29 | let animationFrame = 0;
30 |
31 | function tickSpring(timeDelta: number) {
32 | currentTime += timeDelta;
33 | prevPosition = currentPosition;
34 |
35 | if (zeta < 1) {
36 | const decay = Math.exp(-zeta * omega * currentTime);
37 | const motion =
38 | ((-initialVelocity + zeta * omega * totalDisplacement) / omega_d) *
39 | Math.sin(omega_d * currentTime) +
40 | totalDisplacement * Math.cos(omega_d * currentTime);
41 | currentPosition = to - decay * motion;
42 | } else {
43 | const decay = Math.exp(-omega * currentTime);
44 | const motion =
45 | totalDisplacement +
46 | (-initialVelocity + omega * totalDisplacement) * currentTime;
47 | currentPosition = to - decay * motion;
48 | }
49 |
50 | currentVelocity = (currentPosition - prevPosition) / timeDelta;
51 |
52 | if (
53 | Math.abs(currentVelocity) <= restVelocity &&
54 | Math.abs(to - currentPosition) <= restDisplacement
55 | ) {
56 | currentPosition = to;
57 | complete();
58 | onUpdate && onUpdate(currentPosition);
59 | onComplete && onComplete();
60 | } else onUpdate && onUpdate(currentPosition);
61 | }
62 |
63 | function complete() {
64 | isAnimating = false;
65 | cancelAnimationFrame(animationFrame);
66 | animationFrame = 0;
67 | currentTime = 0;
68 | prevTime = 0;
69 | prevPosition = 0;
70 | currentVelocity = 0;
71 | }
72 |
73 | function step(timestamp: number) {
74 | tickSpring(timestamp - prevTime);
75 | prevTime = timestamp;
76 | if (isAnimating) animationFrame = requestAnimationFrame(step);
77 | }
78 |
79 | function prep(timestamp: number) {
80 | tickSpring(100 / 6);
81 | prevTime = timestamp;
82 | if (isAnimating) animationFrame = requestAnimationFrame(step);
83 | }
84 |
85 | const springInterface: SpringInterface = {
86 | isAnimating: () => isAnimating,
87 | tick: tickSpring,
88 | start(): SpringInterface {
89 | if (totalDisplacement === 0) {
90 | onComplete && onComplete();
91 | return springInterface;
92 | }
93 | if (isAnimating) return springInterface;
94 | isAnimating = true;
95 | animationFrame = requestAnimationFrame(prep);
96 | return springInterface;
97 | },
98 |
99 | stop(): void {
100 | if (isAnimating) complete();
101 | },
102 |
103 | update: function (configs): SpringInterface {
104 | ({
105 | from = currentPosition,
106 | to = to,
107 | stiffness = stiffness,
108 | damping = damping,
109 | initialVelocity = currentVelocity,
110 | mass = mass,
111 | restDisplacement = restDisplacement,
112 | restVelocity = restVelocity,
113 | onUpdate = onUpdate,
114 | onComplete = onComplete,
115 | } = configs);
116 |
117 | if (configs.from != null) currentPosition = from;
118 | if (configs.initialVelocity != null) currentVelocity = 0;
119 | totalDisplacement = to - from;
120 | zeta = damping / (2 * Math.sqrt(stiffness * mass));
121 | omega = Math.sqrt(stiffness / mass) / 1000;
122 | omega_d = omega * Math.sqrt(1.0 - zeta * zeta);
123 | currentTime = 0;
124 | return springInterface;
125 | },
126 |
127 | set: function (configs): SpringInterface {
128 | ({
129 | from = currentPosition,
130 | to = to,
131 | initialVelocity = currentVelocity,
132 | } = configs);
133 |
134 | if (configs.from != null) currentPosition = from;
135 | if (configs.initialVelocity != null) currentVelocity = 0;
136 | totalDisplacement = to - from;
137 | currentTime = 0;
138 | return springInterface;
139 | },
140 | };
141 |
142 | return springInterface;
143 | }
144 |
--------------------------------------------------------------------------------
/src/libs/spring/types.ts:
--------------------------------------------------------------------------------
1 | export type SpringConfigs = {
2 | from?: number;
3 | to?: number;
4 | stiffness?: number;
5 | damping?: number;
6 | mass?: number;
7 | initialVelocity?: number;
8 | restVelocity?: number;
9 | restDisplacement?: number;
10 | onUpdate?: (value: number) => void;
11 | onComplete?: () => void;
12 | };
13 |
14 | export interface SpringInterface {
15 | tick: (timeDelta: number) => void;
16 | start: () => SpringInterface;
17 | stop: () => void;
18 | update: (configs: Partial) => SpringInterface;
19 | set: (
20 | configs: Partial>
21 | ) => SpringInterface;
22 | isAnimating: () => boolean;
23 | }
24 |
25 | export const p = 12;
26 |
--------------------------------------------------------------------------------
/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/src/reportWebVitals.ts:
--------------------------------------------------------------------------------
1 | import { ReportHandler } from 'web-vitals';
2 |
3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => {
4 | if (onPerfEntry && onPerfEntry instanceof Function) {
5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
6 | getCLS(onPerfEntry);
7 | getFID(onPerfEntry);
8 | getFCP(onPerfEntry);
9 | getLCP(onPerfEntry);
10 | getTTFB(onPerfEntry);
11 | });
12 | }
13 | }
14 |
15 | export default reportWebVitals;
16 |
--------------------------------------------------------------------------------
/src/setupTests.ts:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import '@testing-library/jest-dom';
6 |
--------------------------------------------------------------------------------
/src/types/css.d.ts:
--------------------------------------------------------------------------------
1 | import * as CSS from 'csstype';
2 |
3 | declare module 'csstype' {
4 | interface Properties {
5 | [index: string]: any;
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/src/types/react-style-object-to-css.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'react-style-object-to-css';
2 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "esModuleInterop": true,
12 | "allowSyntheticDefaultImports": true,
13 | "strict": true,
14 | "forceConsistentCasingInFileNames": true,
15 | "noFallthroughCasesInSwitch": true,
16 | "module": "esnext",
17 | "moduleResolution": "node",
18 | "resolveJsonModule": true,
19 | "isolatedModules": true,
20 | "noEmit": true,
21 | "jsx": "react-jsx"
22 | },
23 | "include": [
24 | "src"
25 | ]
26 | }
27 |
--------------------------------------------------------------------------------