diff --git a/.babelrc.json b/.babelrc.json new file mode 100644 index 0000000..00ca841 --- /dev/null +++ b/.babelrc.json @@ -0,0 +1,16 @@ +{ + "sourceType": "unambiguous", + "presets": [ + [ + "@babel/preset-env", + { + "targets": { + "chrome": 100 + } + } + ], + "@babel/preset-typescript", + "@babel/preset-react" + ], + "plugins": [] +} diff --git a/.eslintrc.js b/.eslintrc.js index c475d59..7ef2f81 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -9,6 +9,7 @@ module.exports = { 'airbnb', 'plugin:react-hooks/recommended', 'prettier', + 'plugin:storybook/recommended', ], parser: '@typescript-eslint/parser', parserOptions: { @@ -34,11 +35,9 @@ module.exports = { '@typescript-eslint/no-use-before-define': 'error', 'no-shadow': 'off', '@typescript-eslint/no-shadow': 'error', - 'no-void': 'off', 'consistent-return': 'off', 'no-restricted-syntax': 'off', - 'import/prefer-default-export': 'off', 'import/no-extraneous-dependencies': 'off', 'import/extensions': [ @@ -47,12 +46,15 @@ module.exports = { tsx: 'never', }, ], - - 'react/jsx-filename-extension': ['error', { extensions: ['.tsx', '.mdx'] }], + 'react/jsx-filename-extension': [ + 'error', + { + extensions: ['.tsx', '.mdx'], + }, + ], 'react/jsx-props-no-spreading': 'off', 'react/require-default-props': 'off', 'react/no-unused-prop-types': 'off', - 'jsx-a11y/no-static-element-interactions': 'off', }, }; diff --git a/.gitignore b/.gitignore index a44cd8c..106e7ce 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ _/ # storybook storybook-static/ +.env diff --git a/.storybook/main.js b/.storybook/main.js index 4ecca28..823967c 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -1,9 +1,23 @@ module.exports = { stories: ['../examples/**/*.stories.@(js|jsx|ts|tsx)'], addons: [ + '@storybook/preset-create-react-app', '@storybook/addon-links', '@storybook/addon-essentials', '@storybook/addon-interactions', ], - framework: '@storybook/react', + typescript: { + check: false, + checkOptions: {}, + reactDocgen: 'react-docgen-typescript', + reactDocgenTypescriptOptions: { + shouldExtractLiteralValuesFromEnum: true, + propFilter: (prop) => + prop.parent ? !/node_modules/.test(prop.parent.fileName) : true, + }, + }, + framework: { + name: '@storybook/react-webpack5', + options: {}, + }, }; diff --git a/.storybook/webpack.config.js b/.storybook/webpack.config.js new file mode 100644 index 0000000..5a3af63 --- /dev/null +++ b/.storybook/webpack.config.js @@ -0,0 +1,11 @@ +module.exports = ({ config, mode }) => { + config.module.rules.push({ + test: /\.(ts|tsx)$/, + loader: require.resolve('babel-loader'), + options: { + presets: [['react-app', { flow: false, typescript: true }]], + }, + }); + config.resolve.extensions.push('.ts', '.tsx'); + return config; +}; diff --git a/README.md b/README.md index fea28d9..4ca9a74 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ const Demo = () => { #### `lineRenderer`: ({ index: number, active: boolean, line: Line }) => React.ReactNode -The method to render every valid line of parsed lrc. `active` means whether it is current line. `Line` is `LrcLine` when using `Lrc` component or is `MultipleLrcLine` when `MultipleLrc`. +The method to render every valid line of parsed lrc. `active` means whether it is current line. `Line` is `LrcLine` when using `Lrc` component, is `EnhancedLrcLine` when `EnhancedLrc` or is `MultipleLrcLine` when `MultipleLrc`. #### `currentMillisecond`?: number @@ -59,7 +59,7 @@ with verticalSpace: #### `onLineUpdate`?: ({ index: number, line: Line | null }) => void -Call this when current line changed. `Line` is `LrcLine` when using `Lrc` component or is `MultipleLrcLine` when `MultipleLrc`. +Call this when current line changed. `Line` is `LrcLine` when using `Lrc` component, is `EnhancedLrcLine` when `EnhancedLrc`, or is `MultipleLrcLine` when `MultipleLrc`. #### `recoverAutoScrollInterval` @@ -71,6 +71,12 @@ The interval of recovering auto scroll after user scroll. It is `millisecond`, d The lrc. +### Component `EnhancedLrc` + +#### `lrc`: string + +The lrc. + ### Component `MultipleLrc` #### `lrcs`: string[] @@ -135,4 +141,48 @@ const Demo = () => { ## License +[MIT](./LICENSE) + + + + ); +}; +``` + +## Q & A + +### How to prevent user scroll ? + +```jsx + +``` + +### How to hide scrollbar ? + +```scss +.lrc { + /* webkit */ + &::-webkit-scrollbar { + width: 0; + height: 0; + } + + /* firefox */ + scrollbar-width: none; + + /* ie */ + -ms-overflow-style: none; +} +``` + +```jsx + +``` + +## License + [MIT](./LICENSE) diff --git a/examples/data.ts b/examples/data.ts index a0b5d98..2320fff 100644 --- a/examples/data.ts +++ b/examples/data.ts @@ -109,3 +109,15 @@ export const translatedLrc = `[00:13.62]错过了末班电车,我们并肩站 [03:32.43]花了太长的时间,我才察觉到真正重要的是什么 [03:37.38]再不要放开相牵的手,留在我身边 [03:44.58]再次和你携手`; + +export const extraLrc = ` +[00:00.00]Little <00:00.79>jagged <00:01.35>edge<00:02.46> +[00:02.71]I'm <00:03.10>leaning <00:04.01>in <00:04.80>again<00:05.67> +[00:05.92]I <00:06.40>felt <00:06.74>it <00:07.06>when <00:07.58>you <00:08.02>said<00:08.79> +[00:09.04]That <00:09.55>I am <00:10.22>just <00:10.75>a <00:11.14>mess<00:11.85> +[00:12.10]There was <00:12.65>nothing <00:13.25>I <00:13.96>could <00:14.22>do<00:15.36> +[00:15.61]But <00:15.91>see <00:16.25>your <00:16.61>point <00:17.03>of <00:17.31>view<00:18.21> +[00:19.24]<00:19.28> +[00:19.49]And <00:19.73>that's <00:20.19>the <00:20.40>truth<00:21.29> +[00:22.88]<00:22.92> +`; diff --git a/examples/enhanced_lrc/auto_scroll.tsx b/examples/enhanced_lrc/auto_scroll.tsx new file mode 100644 index 0000000..f8187bf --- /dev/null +++ b/examples/enhanced_lrc/auto_scroll.tsx @@ -0,0 +1,104 @@ +/* eslint-disable react/no-unstable-nested-components */ +import React, { CSSProperties } from 'react'; +import styled from 'styled-components'; +import { EnhancedLrc, useRecoverAutoScrollImmediately } from '../..'; +import Control from '../control'; +import useTimer from '../use_timer'; + +const Root = styled.div` + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; + + display: flex; + flex-direction: column; + + > .lrc-box { + position: relative; + + flex: 1; + min-height: 0; + + &::before { + content: ''; + width: 100%; + height: 1px; + + position: absolute; + top: 50%; + left: 0; + + background: rgb(255 0 0 / 0.15); + } + } +`; +const lrcStyle: CSSProperties = { + height: '100%', + padding: '5px 0', +}; + +const Line = styled.div` + min-height: 10px; + padding: 5px 20px; + + font-size: 16px; + text-align: center; +`; + +function LrcDemo({ + lrc, + recoverAutoScrollInterval, + verticalSpace, +}: { + lrc: string; + recoverAutoScrollInterval: number; + verticalSpace: boolean; +}) { + const { currentMillisecond, setCurrentMillisecond, reset, play, pause } = + useTimer(1); + const { signal, recoverAutoScrollImmediately } = + useRecoverAutoScrollImmediately(); + + return ( + + +
+ ( + + {syllables.map((s) => ( + + {s.content} + + ))} + + )} + currentMillisecond={currentMillisecond} + verticalSpace={verticalSpace} + style={lrcStyle} + recoverAutoScrollSingal={signal} + recoverAutoScrollInterval={recoverAutoScrollInterval} + /> +
+
+ ); +} + +export default LrcDemo; diff --git a/examples/enhanced_lrc/index.stories.tsx b/examples/enhanced_lrc/index.stories.tsx new file mode 100644 index 0000000..e2bc423 --- /dev/null +++ b/examples/enhanced_lrc/index.stories.tsx @@ -0,0 +1,37 @@ +/* eslint-disable react/function-component-definition */ +import { StoryObj } from '@storybook/react'; +import { extraLrc } from '../data'; +import AutoScrollComponent from './auto_scroll'; +import StaticComponent from './static'; +import { Renderer } from '../utils'; + +type CompArgs = { + lrc: string; + recoverAutoScrollInterval?: number; + verticalSpace?: boolean; +}; + +export default { + title: 'EnhancedLrc', + component: Renderer, +}; + +export const AutoScroll: StoryObj> = { + args: { + compArgs: { + lrc: extraLrc, + recoverAutoScrollInterval: 5000, + verticalSpace: true, + }, + component: AutoScrollComponent, + }, +}; + +export const Static: StoryObj> = { + args: { + compArgs: { + lrc: extraLrc, + }, + component: StaticComponent, + }, +}; diff --git a/examples/enhanced_lrc/static.tsx b/examples/enhanced_lrc/static.tsx new file mode 100644 index 0000000..9d2eb0f --- /dev/null +++ b/examples/enhanced_lrc/static.tsx @@ -0,0 +1,70 @@ +/* eslint-disable react/no-unstable-nested-components */ +import React from 'react'; +import styled from 'styled-components'; +import { EnhancedLrc } from '../..'; +import { formatMillisecond, formatOffset } from '../utils'; + +const Line = styled.div` + height: 2em; + font-size: 30px; + + > .time { + color: orange; + font-family: monospace; + font-size: 0.7em; + } + + > .anchor:not(under) { + position: relative; + display: inline-block; + white-space: pre; + } + + .under { + color: green; + position: absolute; + top: 2.3rem; + font-size: 0.5em; + font-family: monospace; + rotate: 45deg; + + &::before { + content: '+'; + } + } +`; + +function StaticLrc({ lrc }: { lrc: string }) { + return ( + ( + + + {formatMillisecond(line.startMillisecond)} + +   + {line.syllables.map((syllable) => ( +
+ {syllable.content} + {syllable.content && + formatOffset( + syllable.startMillisecond, + line.startMillisecond, + ) && ( +
+ {formatOffset( + syllable.startMillisecond, + line.startMillisecond, + )} +
+ )} +
+ ))} +
+ )} + /> + ); +} + +export default StaticLrc; diff --git a/examples/lrc/index.stories.tsx b/examples/lrc/index.stories.tsx index 1ab143f..6aaa589 100644 --- a/examples/lrc/index.stories.tsx +++ b/examples/lrc/index.stories.tsx @@ -1,28 +1,37 @@ /* eslint-disable react/function-component-definition */ -import React from 'react'; -import { ComponentStory } from '@storybook/react'; -import StaticComponent from './static'; -import AutoScrollComponent from './auto_scroll'; +import { StoryObj } from '@storybook/react'; import { lrc } from '../data'; +import AutoScrollComponent from './auto_scroll'; +import StaticComponent from './static'; +import { Renderer } from '../utils'; + +type CompArgs = { + lrc: string; + recoverAutoScrollInterval?: number; + verticalSpace?: boolean; +}; export default { title: 'Lrc', + component: Renderer, }; -const AutoScrollTemplate: ComponentStory = ( - args, -) => ; -export const AutoScroll = AutoScrollTemplate.bind({}); -AutoScroll.args = { - lrc, - recoverAutoScrollInterval: 5000, - verticalSpace: true, +export const AutoScroll: StoryObj> = { + args: { + compArgs: { + lrc, + recoverAutoScrollInterval: 5000, + verticalSpace: true, + }, + component: AutoScrollComponent, + }, }; -const StaticTemplate: ComponentStory = (args) => ( - -); -export const Static = StaticTemplate.bind({}); -Static.args = { - lrc, +export const Static: StoryObj> = { + args: { + compArgs: { + lrc, + }, + component: StaticComponent, + }, }; diff --git a/examples/multiple_lrc/index.stories.tsx b/examples/multiple_lrc/index.stories.tsx index 616a0f5..1fd4fdb 100644 --- a/examples/multiple_lrc/index.stories.tsx +++ b/examples/multiple_lrc/index.stories.tsx @@ -1,28 +1,37 @@ /* eslint-disable react/function-component-definition */ -import React from 'react'; -import { ComponentStory } from '@storybook/react'; -import StaticComponent from './static'; +import { StoryObj } from '@storybook/react'; import { originalLrc, translatedLrc } from '../data'; import AutoScrollComponent from './auto_scroll'; +import StaticComponent from './static'; +import { Renderer } from '../utils'; + +type CompArgs = { + lrcs: string[]; + recoverAutoScrollInterval?: number; + verticalSpace?: boolean; +}; export default { title: 'MultipleLrc', + component: Renderer, }; -const AutoScrollTemplate: ComponentStory = ( - args, -) => ; -export const AutoScroll = AutoScrollTemplate.bind({}); -AutoScroll.args = { - lrcs: [originalLrc, translatedLrc], - recoverAutoScrollInterval: 5000, - verticalSpace: true, +export const AutoScroll: StoryObj> = { + args: { + compArgs: { + lrcs: [originalLrc, translatedLrc], + recoverAutoScrollInterval: 5000, + verticalSpace: true, + }, + component: AutoScrollComponent, + }, }; -const StaticTemplate: ComponentStory = (args) => ( - -); -export const Static = StaticTemplate.bind({}); -Static.args = { - lrcs: [originalLrc, translatedLrc], +export const Static: StoryObj> = { + args: { + compArgs: { + lrcs: [originalLrc, translatedLrc], + }, + component: StaticComponent, + }, }; diff --git a/examples/utils.ts b/examples/utils.ts index 4d592f9..21c45bc 100644 --- a/examples/utils.ts +++ b/examples/utils.ts @@ -1,3 +1,5 @@ +import { Component } from 'react'; + export function formatMillisecond(ms: number) { const minute = Math.floor(ms / 1000 / 60); const second = Math.floor(ms / 1000) % 60; @@ -6,3 +8,18 @@ export function formatMillisecond(ms: number) { .toString() .padStart(2, '0')}.${millisecond.toString().padStart(3, '0')}`; } + +export function formatOffset(ms: number, offset: number) { + const msOffset = ms - offset; + return msOffset ? msOffset.toPrecision(2).slice(0, 3) : ''; +} + +export function Renderer({ + compArgs, + component, +}: { + compArgs: ArgType; + component: (args: ArgType) => Component; +}) { + return component(compArgs); +} diff --git a/package.json b/package.json index 80f71cb..3300488 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,8 @@ "build": "rimraf build && tsc --declaration", "prepublish": "npm run build", "lint-staged": "lint-staged", - "storybook": "start-storybook -p 6006", - "build-storybook": "build-storybook" + "storybook": "storybook dev -p 6006", + "build-storybook": "storybook build" }, "author": { "name": "mebtte", @@ -37,25 +37,34 @@ "react-dom": ">=16.8.0" }, "dependencies": { - "clrc": "^3.0.0", + "@storybook/preset-create-react-app": "^7.0.6", + "@storybook/react-webpack5": "^7.0.6", + "@types/ws": "^8.5.4", + "clrc": "github:Nafeij/clrc", + "react-scripts": "5.0.0", "resize-observer-polyfill": "^1.5.1" }, "devDependencies": { "@babel/core": "^7.19.0", - "@storybook/addon-actions": "^6.5.10", - "@storybook/addon-essentials": "^6.5.10", - "@storybook/addon-interactions": "^6.5.10", - "@storybook/addon-links": "^6.5.10", - "@storybook/builder-webpack4": "^6.5.10", - "@storybook/manager-webpack4": "^6.5.10", - "@storybook/react": "^6.5.10", - "@storybook/testing-library": "^0.0.13", + "@babel/preset-env": "^7.21.4", + "@babel/preset-react": "^7.18.6", + "@babel/preset-typescript": "^7.21.4", + "@storybook/addon-actions": "^7.0.6", + "@storybook/addon-docs": "^7.0.6", + "@storybook/addon-essentials": "^7.0.6", + "@storybook/addon-interactions": "^7.0.6", + "@storybook/addon-links": "^7.0.6", + "@storybook/addon-mdx-gfm": "^7.0.6", + "@storybook/react": "^7.0.6", + "@storybook/testing-library": "^0.0.14-next.2", + "@types/node": "^18.15.12", "@types/react": "^16.14.31", "@types/react-dom": "^16.9.16", "@types/styled-components": "^5.1.26", "@typescript-eslint/eslint-plugin": "^5.36.2", "@typescript-eslint/parser": "^5.36.2", "babel-loader": "^8.2.5", + "dotenv-webpack": "^8.0.1", "eslint": "^8.23.0", "eslint-config-airbnb": "^19.0.4", "eslint-config-prettier": "^8.5.0", @@ -63,13 +72,16 @@ "eslint-plugin-jsx-a11y": "^6.6.1", "eslint-plugin-react": "^7.31.7", "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-storybook": "^0.6.11", "husky": "^8.0.1", "lint-staged": "^13.0.3", "prettier": "^2.7.1", "react": "^16.14.0", "react-dom": "^16.14.0", "rimraf": "^3.0.2", + "storybook": "^7.0.6", "styled-components": "^5.3.5", - "typescript": "^4.8.2" + "typescript": "^4.8.2", + "webpack": "^5.80.0" } } diff --git a/src/components/enhanced_lrc/constants.ts b/src/components/enhanced_lrc/constants.ts new file mode 100644 index 0000000..3345c0f --- /dev/null +++ b/src/components/enhanced_lrc/constants.ts @@ -0,0 +1,17 @@ +import { BaseLine, BaseProps } from '../../constants'; + +export interface Syllable extends BaseLine { + sylNumber: number; + raw: string; + content: string; +} +export interface Line extends BaseLine { + lineNumber: number; + raw: string; + content: string; + syllables: Syllable[]; +} + +export interface Props extends BaseProps { + lrc: string; +} diff --git a/src/components/enhanced_lrc/enhanced_lrc.tsx b/src/components/enhanced_lrc/enhanced_lrc.tsx new file mode 100644 index 0000000..88c0c2b --- /dev/null +++ b/src/components/enhanced_lrc/enhanced_lrc.tsx @@ -0,0 +1,16 @@ +import React, { ForwardedRef, forwardRef, HtmlHTMLAttributes } from 'react'; +import BaseLrc from '../base_lrc'; +import { Props, Line } from './constants'; +import useEnhLrc from './use_enhanced_lrc'; + +const Lrc = forwardRef( + ( + { lrc, ...props }: Props & HtmlHTMLAttributes, + ref: ForwardedRef, + ) => { + const lines = useEnhLrc(lrc); + return {...props} lines={lines} ref={ref} />; + }, +); + +export default Lrc; diff --git a/src/components/enhanced_lrc/index.ts b/src/components/enhanced_lrc/index.ts new file mode 100644 index 0000000..6e898a2 --- /dev/null +++ b/src/components/enhanced_lrc/index.ts @@ -0,0 +1,5 @@ +import Lrc from './enhanced_lrc'; +import { Line, Syllable, Props } from './constants'; + +export { Line, Syllable, Props }; +export default Lrc; diff --git a/src/components/enhanced_lrc/use_enhanced_lrc.ts b/src/components/enhanced_lrc/use_enhanced_lrc.ts new file mode 100644 index 0000000..99d885f --- /dev/null +++ b/src/components/enhanced_lrc/use_enhanced_lrc.ts @@ -0,0 +1,41 @@ +import { LyricExtLine as ClrcLyricExtLine, LineType, parse } from 'clrc'; +import { useMemo } from 'react'; +import getRandomString from '../../utils/get_random_string'; +import { Line } from './constants'; + +type timed = { startMillisecond: number }; + +const byTime = (a: timed, b: timed) => a.startMillisecond - b.startMillisecond; + +function useEnhLrc(lrc: string) { + const lines = useMemo( + () => + ( + parse(lrc, { enhanced: true }).filter( + (line) => line.type === LineType.LYRIC_ENH, + ) as ClrcLyricExtLine[] + ) + .map((l) => ({ + id: getRandomString(), + lineNumber: l.lineNumber, + raw: l.raw, + startMillisecond: l.startMillisecond, + content: l.content, + syllables: l.syllables + .map((s) => ({ + id: getRandomString(), + sylNumber: s.sylNumber, + raw: s.raw, + startMillisecond: s.startMillisecond, + content: s.content, + })) + .sort(byTime), + })) + .sort(byTime), + [lrc], + ); + + return lines; +} + +export default useEnhLrc; diff --git a/src/index.ts b/src/index.ts index 83fa5c6..2df8398 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,11 @@ import MultipleLrc, { Line as MultipleLrcLine, Props as MultipleLrcProps, } from './components/multiple_lrc'; +import EnhancedLrc, { + Line as EnhancedLrcLine, + Props as EnhancedLrcProps, + Syllable, +} from './components/enhanced_lrc'; import useRecoverAutoScrollImmediately from './utils/use_recover_auto_scroll_immediately'; export { @@ -12,5 +17,9 @@ export { MultipleLrc, MultipleLrcLine, MultipleLrcProps, + EnhancedLrc, + EnhancedLrcLine, + EnhancedLrcProps, + Syllable, useRecoverAutoScrollImmediately, };