├── .babelrc ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── src ├── example.js ├── index.js ├── pad-function.js ├── seconds-sizes.js ├── timeline-frame.jsx ├── timeline-frames.jsx ├── timeline-ruler.jsx ├── timeline.jsx └── timeline.scss └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["env", "react"] 3 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | .vscode 61 | dist -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Directory for instrumented libs generated by jscoverage/JSCover 2 | lib-cov 3 | 4 | # Coverage directory used by tools like istanbul 5 | coverage 6 | 7 | # nyc test coverage 8 | .nyc_output 9 | 10 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 11 | .grunt 12 | 13 | # Optional npm cache directory 14 | .npm 15 | 16 | # Optional eslint cache 17 | .eslintcache 18 | 19 | # Optional REPL history 20 | .node_repl_history 21 | 22 | # Output of 'npm pack' 23 | *.tgz 24 | 25 | # Yarn Integrity file 26 | .yarn-integrity 27 | 28 | # dotenv environment variables file 29 | .env 30 | .vscode -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Kevin Herrarte 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Timeline Editor 2 | 3 | [![npm](https://img.shields.io/npm/v/timeline-editor-react.svg)](https://www.npmjs.com/package/timeline-editor-react) 4 | [![npm](https://img.shields.io/npm/dm/timeline-editor-react.svg)](https://www.npmjs.com/package/timeline-editor-react) 5 | [![npm](https://img.shields.io/npm/l/timeline-editor-react.svg)](https://www.npmjs.com/package/timeline-editor-react) 6 | 7 | 8 | Timeline Editor is a React component for building a Timeline like that of a video editor. 9 | 10 | Features of `timeline-editor-react` 11 | * Drag and Drop frames 12 | * Lightweight ~13kb (gzipped, excluding react) 13 | 14 | 15 | ## Getting started 16 | 17 | ``` 18 | npm install timeline-editor-react 19 | ``` 20 | 21 | 22 | ### Example 23 | See [`src/example.js`](https://github.com/kevintech/timeline-editor-react/blob/master/src/example.js) 24 | ```js 25 | import React from "react"; 26 | import ReactDOM from "react-dom"; 27 | import Timeline from "./index"; 28 | 29 | var layers = [ 30 | { 31 | id: "3d1df1b4-4d9d-45a4-bf14-cb580ee74675", 32 | name: "Left" 33 | }, 34 | { 35 | id: "7d8c4210-0cfa-4a10-8b21-01e6601e00bf", 36 | name: "Top" 37 | }, 38 | { 39 | id: "65079f30-47a8-4469-833e-4f0eea04d233", 40 | name: "Bottom" 41 | } 42 | ]; 43 | var frames = { 44 | "3d1df1b4-4d9d-45a4-bf14-cb580ee74675": [{ 45 | name: "Hello.png", 46 | second: 0, 47 | duration: 70 48 | }, 49 | { 50 | name: "Welcome.png", 51 | second: 130, 52 | duration: 200 53 | }], 54 | "7d8c4210-0cfa-4a10-8b21-01e6601e00bf": [{ 55 | name: "Goodbye.png", 56 | second: 10, 57 | duration: 150 58 | }], 59 | "65079f30-47a8-4469-833e-4f0eea04d233": [] 60 | }; 61 | 62 | function onUpdateFrames(frames) { 63 | //TODO: deal with frames 64 | } 65 | 66 | ReactDOM.render( 67 | , 68 | document.getElementById("root") 69 | ); 70 | ``` 71 | 72 | 73 | # Props 74 | 75 | * `layers`: (required) Array of objects, see example above, 76 | * Available Properties 77 | * `id` - String, id of the layer 78 | * `name` - String, name or title displayed on the list 79 | * `frames`: (required) Object, see example above, 80 | * `key` - String, layer id where the frames are placed 81 | * `value` - Array of objects 82 | * Available Properties 83 | * `name` - String, name or title displayed on the frame 84 | * `second` - Number, initial second of the frame 85 | * `duration` - Number, duration of the frame in seconds 86 | * `onUpdateFrames`: Function, `callback(frames)` 87 | * Use this to get notified when the user updates the frames, see example above 88 | ```javascript 89 | _onUpdateFrames(frames) { 90 | console.log(frames); 91 | } 92 | ``` 93 | 94 | 95 | # Contributing 96 | 97 | * Comment your code 98 | * Describe your feature/implementation in the pullrequest 99 | * Write [clean](https://github.com/ryanmcdermott/clean-code-javascript) code 100 | 101 | 102 | # License 103 | 104 | MIT 105 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "timeline-editor-react", 3 | "version": "1.0.3", 4 | "description": "", 5 | "main": "dist/main.js", 6 | "scripts": { 7 | "webpack": "webpack", 8 | "build": "webpack --mode production", 9 | "start:dev": "webpack-dev-server", 10 | "test": "echo \"Error: no test specified\" && exit 1" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://kevintech@github.com/kevintech/timeline-editor-react.git" 15 | }, 16 | "keywords": [ 17 | "react", 18 | "timeline", 19 | "video", 20 | "editor" 21 | ], 22 | "author": "Kevin Herrarte", 23 | "license": "MIT", 24 | "bugs": { 25 | "url": "https://github.com/kevintech/timeline-editor-react/issues" 26 | }, 27 | "homepage": "https://github.com/kevintech/timeline-editor-react#readme", 28 | "peerDependencies": { 29 | "react": "^16.2.0", 30 | "react-dom": "^16.2.0", 31 | "sass-loader": "^6.0.7" 32 | }, 33 | "devDependencies": { 34 | "babel-loader": "^7.1.3", 35 | "babel-preset-env": "^1.7.0", 36 | "babel-preset-react": "^6.24.1", 37 | "css-loader": "^0.28.10", 38 | "node-sass": "^4.9.0", 39 | "react": "^16.4.1", 40 | "react-dom": "^16.4.1", 41 | "sass-loader": "^6.0.7", 42 | "style-loader": "^0.20.2", 43 | "webpack": "^4.14.0", 44 | "webpack-cli": "^2.1.5", 45 | "webpack-dev-server": "^3.1.4" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/example.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import Timeline from "timeline-editor-react"; 4 | 5 | var layers = [ 6 | { 7 | id: "3d1df1b4-4d9d-45a4-bf14-cb580ee74675", 8 | name: "Left" 9 | }, 10 | { 11 | id: "7d8c4210-0cfa-4a10-8b21-01e6601e00bf", 12 | name: "Top" 13 | }, 14 | { 15 | id: "65079f30-47a8-4469-833e-4f0eea04d233", 16 | name: "Bottom" 17 | } 18 | ]; 19 | var frames = { 20 | "3d1df1b4-4d9d-45a4-bf14-cb580ee74675": [{ 21 | name: "Hello.png", 22 | second: 0, 23 | duration: 70 24 | }, 25 | { 26 | name: "Welcome.png", 27 | second: 130, 28 | duration: 200 29 | }], 30 | "7d8c4210-0cfa-4a10-8b21-01e6601e00bf": [{ 31 | name: "Goodbye.png", 32 | second: 10, 33 | duration: 150 34 | }], 35 | "65079f30-47a8-4469-833e-4f0eea04d233": [] 36 | }; 37 | 38 | function onUpdateFrames(frames) { 39 | //TODO: deal with frames 40 | } 41 | 42 | ReactDOM.render( 43 | , 44 | document.getElementById("root") 45 | ); -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { default as default } from "./timeline.jsx"; -------------------------------------------------------------------------------- /src/pad-function.js: -------------------------------------------------------------------------------- 1 | export default function padNumber(number, size) { 2 | var s = String(number); 3 | while (s.length < (size || 2)) {s = "0" + s;} 4 | return s; 5 | } -------------------------------------------------------------------------------- /src/seconds-sizes.js: -------------------------------------------------------------------------------- 1 | export const DEFAULT_SECONDS_RULER = 60; 2 | export const RULER_EXTRA_PIXEL = 10; 3 | export const SECONDS_LENGTH = 10; 4 | export const SECONDS_CHUNKS = 10; -------------------------------------------------------------------------------- /src/timeline-frame.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default class TimelineFrame extends React.Component { 4 | 5 | constructor(props) { 6 | super(props); 7 | } 8 | 9 | handleMouseClick(e) { 10 | this.props.dragEvent(e, this.props.layerKey, this.props.index); 11 | } 12 | 13 | render() { 14 | return ( 15 | this.handleMouseClick(e)}> 18 | {this.props.frame.name} 19 | 20 | ); 21 | } 22 | } -------------------------------------------------------------------------------- /src/timeline-frames.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TimelineRuler from "./timeline-ruler.jsx"; 3 | import TimelineFrame from "./timeline-frame.jsx"; 4 | import { SECONDS_LENGTH } from "./seconds-sizes"; 5 | 6 | export default class TimelineFrames extends React.Component { 7 | 8 | constructor(props) { 9 | super(props); 10 | this.state = {frames: props.frames, seconds: props.seconds, 11 | layers: props.layers, draggable: null}; 12 | } 13 | 14 | handleFrameDragStart(e, layerKey, frameIndex) { 15 | const clientX = e.clientX; 16 | this.setState({draggable:{ 17 | currentLayer: layerKey, 18 | currentElementIndex: frameIndex, 19 | startX: clientX 20 | }}); 21 | } 22 | 23 | handleFrameDraggin(e) { 24 | if(!this.state.draggable) return; 25 | const currentClientX = e.clientX; 26 | const moveMouseX = currentClientX - this.state.draggable.startX; 27 | //TODO: move elements by 10? 28 | // if ((Math.abs(moveMouseX)%10)!==0) return; 29 | const index = this.state.draggable.currentElementIndex; 30 | const layerKey = this.state.draggable.currentLayer; 31 | var frames = this.state.frames; 32 | frames[layerKey][index].second = frames[layerKey][index].second + moveMouseX; 33 | this.setState({frames: frames, draggable: { 34 | currentLayer: layerKey, 35 | currentElementIndex: index, 36 | startX: currentClientX, 37 | }}); 38 | this.props.updateFrames(frames); 39 | } 40 | 41 | handleFrameDragEnd() { 42 | this.setState({draggable:null}); 43 | } 44 | 45 | render() { 46 | return ( 47 |
this.handleFrameDragEnd()}> 49 | 50 | {this.state.layers.map((layer) => 51 |
52 |
this.handleFrameDraggin(e)}> 55 | {this.state.frames[layer.id] && 56 | this.state.frames[layer.id].map((frame, index) => 57 | 60 | )} 61 |
62 |
63 | )} 64 |
65 | ); 66 | } 67 | } -------------------------------------------------------------------------------- /src/timeline-ruler.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import padNumber from './pad-function'; 3 | import { DEFAULT_SECONDS_RULER, SECONDS_CHUNKS, 4 | SECONDS_LENGTH, RULER_EXTRA_PIXEL } from "./seconds-sizes"; 5 | 6 | export default class TimelineRuler extends React.Component { 7 | 8 | constructor(props) { 9 | super(props); 10 | let secondsLength = props.seconds>0 ? props.seconds : DEFAULT_SECONDS_RULER; 11 | this.state = {secondsList: this.getSecondsList(secondsLength)}; 12 | } 13 | 14 | getSecondsList(seconds) { 15 | var list = []; 16 | var second = 10; 17 | do 18 | { 19 | let currentMinute = Math.floor(second/60); 20 | let currentSecond = (second - (currentMinute*60)); 21 | list.push(`${padNumber(currentMinute,2)}:${padNumber(currentSecond,2)}`); 22 | second = second + 10; 23 | } 24 | while(second<=seconds) 25 | return list; 26 | } 27 | 28 | render() { 29 | return ( 30 |
32 | {this.state.secondsList.map((second)=> 33 |
34 | {second} 35 |
36 |
37 |
38 |
39 |
40 |
41 | )} 42 |
43 | ); 44 | } 45 | } -------------------------------------------------------------------------------- /src/timeline.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import TimelineFrames from "./timeline-frames.jsx"; 4 | import PropTypes from "prop-types"; 5 | import "./timeline.scss"; 6 | 7 | class Timeline extends React.Component { 8 | 9 | constructor(props) { 10 | super(props); 11 | this.state = {layers: props.layers, frames: props.frames}; 12 | } 13 | 14 | updateFrames(framesUpdated) { 15 | this.setState({frames: framesUpdated}); 16 | this.props.onUpdateFrames(framesUpdated); 17 | } 18 | 19 | render() { 20 | return
21 |
22 |
23 |
Layers
24 |
    25 | {this.state.layers.map((layer)=> 26 |
  • {layer.name}
  • 27 | )} 28 |
29 |
30 | 33 |
34 |
35 | } 36 | } 37 | 38 | Timeline.propTypes = { 39 | layers: PropTypes.arrayOf(PropTypes.shape({ 40 | id: PropTypes.string, 41 | name: PropTypes.string 42 | })), 43 | frames: PropTypes.objectOf(PropTypes.arrayOf( 44 | PropTypes.shape({ 45 | name: PropTypes.string, 46 | second: PropTypes.number, 47 | duration: PropTypes.number 48 | }) 49 | )) 50 | } 51 | 52 | export default Timeline; -------------------------------------------------------------------------------- /src/timeline.scss: -------------------------------------------------------------------------------- 1 | $container_border_color: #555; 2 | $timeline_frame_10s_size: 100px; 3 | $timeline_frame_background_color: #FAA; 4 | $timeline_frame_text_color: #000; 5 | $timeline_layer_background_color: #FF0; 6 | $timeline_layer_header_background_color: #D65; 7 | $timeline_layer_header_text_color: #865; 8 | $timeline_layer_text_color: #91F; 9 | $timeline_ruler_background_color: #3AA; 10 | $timeline_ruler_text_color: #55B; 11 | 12 | .timeline-editor { 13 | &__container { 14 | border: 1px solid $container_border_color; 15 | display: table; 16 | table-layout: fixed; 17 | width: 100%; 18 | } 19 | 20 | &__frames { 21 | display: table-cell; 22 | overflow-x: auto; 23 | vertical-align: top; 24 | 25 | &-layer { 26 | border-bottom: 1px solid rgba(0,0,0,0.1); 27 | height: 1.5em; 28 | padding: 1px 0; 29 | position: relative; 30 | 31 | &__item { 32 | background-color: $timeline_frame_background_color; 33 | box-shadow: 0 0 1px black inset; 34 | box-sizing: border-box; 35 | color: $timeline_frame_text_color; 36 | cursor: move; 37 | display: inline-block; 38 | left: 0; 39 | min-width: 10px; 40 | overflow-x: hidden; 41 | padding: 0.25em 0.5em; 42 | position: absolute; 43 | top: 0; 44 | user-select: none; 45 | 46 | &:after { 47 | content: "."; 48 | cursor: ew-resize; 49 | font-size: 0; 50 | height: 100%; 51 | position: absolute; 52 | right: 0; 53 | top: 0; 54 | width: 3px; 55 | } 56 | } 57 | } 58 | 59 | &-ruler { 60 | background-color: $timeline_ruler_background_color; 61 | border-bottom: 1px solid black; 62 | color: $timeline_ruler_text_color; 63 | font-size: 16px; 64 | height: 1.5em; 65 | min-width: 100%; 66 | 67 | &__second { 68 | border-right: 1px solid black; 69 | bottom: 0; 70 | height: 8px; 71 | position: absolute; 72 | 73 | &:nth-of-type(1) { 74 | left: 20px; 75 | } 76 | &:nth-of-type(2) { 77 | left: 40px; 78 | } 79 | &:nth-of-type(3) { 80 | left: 60px; 81 | } 82 | &:nth-of-type(4) { 83 | left: 80px; 84 | } 85 | &:nth-of-type(5) { 86 | height: 16px; 87 | right: 0px; 88 | } 89 | } 90 | 91 | &-s10s { 92 | display: inline-block; 93 | font-size: 14px; 94 | height: 100%; 95 | padding: 0; 96 | position: relative; 97 | text-align: center; 98 | width: $timeline_frame_10s_size; 99 | 100 | span { 101 | font-style: italic; 102 | } 103 | } 104 | } 105 | } 106 | 107 | &__layers { 108 | background-color: $timeline_layer_header_background_color; 109 | border-right: 2px solid rgba(0,0,0,0.5); 110 | color: $timeline_layer_header_text_color; 111 | display: table-cell; 112 | vertical-align: top; 113 | width: 200px; 114 | 115 | &-header { 116 | border-bottom: 1px solid black; 117 | font-weight: 600; 118 | line-height: 1em; 119 | padding: 0.25em 0; 120 | text-align: center; 121 | } 122 | 123 | ul { 124 | list-style-type: none; 125 | margin: 0; 126 | padding: 0; 127 | 128 | li { 129 | background-color: $timeline_layer_background_color; 130 | border-bottom: 1px solid rgba(0,0,0,0.5); 131 | color: $timeline_layer_text_color; 132 | display: block; 133 | padding: 0.25em; 134 | } 135 | } 136 | } 137 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | mode: "development", 5 | entry: './src/index.js', 6 | output: { 7 | filename: '[name].js', 8 | path: path.resolve(__dirname, 'dist'), 9 | library: 'Timeline', 10 | libraryTarget: 'umd' 11 | }, 12 | module: { 13 | rules: [ 14 | { 15 | test: /\.(js|jsx)$/, 16 | exclude: /node_modules/, 17 | use: ['babel-loader'] 18 | }, 19 | { 20 | test: /\.scss$/, 21 | use: ['style-loader', 'css-loader', 'sass-loader'] 22 | } 23 | ] 24 | }, 25 | externals: { 26 | react: 'react', 27 | 'react-dom': 'react-dom' 28 | }, 29 | devServer: { 30 | contentBase: path.join(__dirname, "dist"), 31 | compress: true, 32 | port: 9000 33 | } 34 | }; 35 | --------------------------------------------------------------------------------