├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── example ├── app.jsx ├── dist │ └── index.html └── screen.scss ├── gulpfile.js ├── lib ├── index.jsx ├── models │ ├── manager.js │ └── window.js └── views │ ├── manager.jsx │ └── window.jsx ├── package.json └── test └── models ├── manager.js └── window.js /.gitignore: -------------------------------------------------------------------------------- 1 | /example/dist/js/*.js 2 | /example/dist/css/*.css 3 | /node_modules 4 | /pkg 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /example 2 | /lib 3 | /test 4 | /gulpfile.js 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 George Czabania 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ReactWM 2 | ======= 3 | 4 | A minimal window manager built using React. 5 | 6 | ## Install 7 | 8 | ``` 9 | npm install --save reactwm 10 | ``` 11 | 12 | ## Example App 13 | 14 | ``` 15 | > npm start 16 | ``` 17 | 18 | Then open http://localhost:8000 19 | 20 | ## Usage 21 | 22 | ```javscript 23 | var React = require('react'); 24 | var ReactWM = require('reactwm'); 25 | 26 | var Settings = require('./views/settings'); 27 | 28 | var manager = new ReactWM.Manager(); 29 | 30 | React.renderComponent( 31 | , 32 | document.body 33 | ); 34 | 35 | manager.open(, { 36 | id: 'settings', 37 | x: 20, 38 | y: 20, 39 | width: 300, 40 | height: 400, 41 | title: 'Settings' 42 | }); 43 | ``` 44 | -------------------------------------------------------------------------------- /example/app.jsx: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var $ = require('jquery'); 4 | var _ = require('lodash'); 5 | var React = require('react'); 6 | var ReactWM = require('../lib/'); 7 | 8 | 9 | var Settings = React.createClass({ 10 | getInitialState: function () { 11 | return { 12 | name: 'Sam' 13 | }; 14 | }, 15 | save: function () { 16 | this.setState({ 17 | name: this.refs.name.getDOMNode().value 18 | }); 19 | }, 20 | handleFocus: function () { 21 | this.refs.name.getDOMNode().focus(); 22 | }, 23 | render: function () { 24 | return ( 25 |
26 | 27 | 28 | 29 |
30 |

My name is: {this.state.name}

31 |
32 | ); 33 | } 34 | }); 35 | 36 | 37 | 38 | $(function () { 39 | 40 | var data = localStorage.windows ? JSON.parse(localStorage.windows) : []; 41 | 42 | var manager = window.m = new ReactWM.Manager(data); 43 | 44 | manager.allWindows().forEach(function (window) { 45 | window.setComponent(); 46 | }); 47 | 48 | var save = _.debounce(function () { 49 | localStorage.windows = manager.toString(); 50 | }, 1000); 51 | 52 | manager.on('change', save); 53 | manager.on('change:windows', save); 54 | 55 | React.renderComponent(( 56 | 57 | ), $('.content')[0]); 58 | 59 | $('.add-window').on('click', function () { 60 | var id = Date.now(); 61 | 62 | manager.open('settings-' + id, , { 63 | title: 'Settings ' + id, 64 | width: 300, 65 | height: 300, 66 | x: 20, 67 | y: 20 68 | }); 69 | }); 70 | 71 | var openWin1 = function () { 72 | manager.open('settings-1', , { 73 | title: 'Settings 1', 74 | width: 300, 75 | height: 300, 76 | x: 20, 77 | y: 20 78 | }); 79 | }; 80 | 81 | var openWin2 = function () { 82 | manager.open('settings-2', , { 83 | title: 'Settings 2', 84 | width: 300, 85 | height: 300, 86 | x: 20, 87 | y: 20 88 | }); 89 | }; 90 | 91 | $('.open-win-1').on('click', openWin1); 92 | $('.open-win-2').on('click', openWin2); 93 | 94 | }); 95 | 96 | -------------------------------------------------------------------------------- /example/dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ReactWM 6 | 7 | 8 | 9 | 10 |
11 | ReactWM 12 | Add Window 13 | Open Win 1 14 | Open Win 2 15 |
16 |
17 | 18 | 19 | -------------------------------------------------------------------------------- /example/screen.scss: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | user-select: none; 4 | cursor: default; 5 | font: 12px/18px 'Open Sans', sans-serif; 6 | position: fixed; 7 | top: 0; bottom: 0; 8 | width: 100%; 9 | 10 | & > header { 11 | position: absolute; 12 | top: 0; 13 | width: 100%; 14 | height: 30px; 15 | line-height: 30px; 16 | background: #2c3e50; 17 | color: #fff; 18 | padding: 0; 19 | 20 | span { 21 | padding: 0 20px; 22 | display: inline-block; 23 | } 24 | 25 | .title { 26 | background: #e74c3c; 27 | } 28 | 29 | .clickable:hover { 30 | background: rgba(0, 0, 0, 0.3); 31 | } 32 | 33 | } 34 | 35 | & > .content { 36 | position: absolute; 37 | top: 30px; bottom: 0; 38 | width: 100%; 39 | background: #34495e; 40 | overflow: hidden; 41 | } 42 | 43 | } 44 | 45 | .window-manager { 46 | position: relative; 47 | } 48 | 49 | .windows { 50 | position: absolute; 51 | top: 0; bottom: 0; 52 | left: 0; right: 0; 53 | } 54 | 55 | .window { 56 | position: absolute; 57 | background: #fff; 58 | box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.5); 59 | background: #2980b9; 60 | overflow: hidden; 61 | transition: box-shadow 0.15s ease; 62 | 63 | &.active { 64 | background: #3498db; 65 | box-shadow: 0 3px 20px 0 rgba(0, 0, 0, 0.6); 66 | } 67 | 68 | $header_height: 30px; 69 | 70 | header { 71 | position: absolute; 72 | top: 0; 73 | height: $header_height; 74 | left: 0; 75 | right: 0; 76 | line-height: $header_height; 77 | cursor: move; 78 | 79 | .title { 80 | margin-left: $header_height / 4; 81 | float: left; 82 | color: #fff; 83 | } 84 | 85 | .close { 86 | float: right; 87 | height: $header_height; 88 | width: $header_height; 89 | transition: background 0.15s ease; 90 | cursor: default; 91 | 92 | &:after { 93 | content: 'x'; 94 | font-size: 15px; 95 | display: block; 96 | text-align: center; 97 | width: $header_height; 98 | line-height: $header_height; 99 | color: #fff; 100 | } 101 | 102 | &:hover { 103 | background: rgba(0, 0, 0, 0.4); 104 | } 105 | } 106 | 107 | } 108 | 109 | .content { 110 | $padding: 3px; 111 | background: #fff; 112 | position: absolute; 113 | top: $header_height; 114 | bottom: $padding; 115 | left: $padding; 116 | right: $padding; 117 | } 118 | 119 | .resize { 120 | position: absolute; 121 | bottom: 0; 122 | 123 | &.se-resize { 124 | right: 0; 125 | cursor: se-resize; 126 | width: 10px; 127 | height: 10px; 128 | } 129 | 130 | &.sw-resize { 131 | left: 0; 132 | cursor: sw-resize; 133 | width: 10px; 134 | height: 10px; 135 | } 136 | 137 | &.s-resize { 138 | left: 0; 139 | right: 0; 140 | cursor: ns-resize; 141 | height: 10px; 142 | } 143 | 144 | &.e-resize { 145 | right: 0; 146 | top: $header_height; 147 | cursor: ew-resize; 148 | width: 10px; 149 | } 150 | 151 | &.w-resize { 152 | left: 0; 153 | top: $header_height; 154 | cursor: ew-resize; 155 | width: 10px; 156 | } 157 | 158 | } 159 | } 160 | 161 | .settings { 162 | padding: 20px; 163 | } 164 | 165 | $fade: opacity 1s ease-in; 166 | 167 | .transition-enter { 168 | opacity: 0; 169 | transition: $fade; 170 | } 171 | 172 | .transition-enter.transition-enter-active { 173 | opacity: 1; 174 | } 175 | 176 | .transition-leave { 177 | opacity: 1; 178 | transition: $fade; 179 | } 180 | 181 | .transition-leave.transition-leave-active { 182 | opacity: 0; 183 | } 184 | 185 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | var gulp = require('gulp'); 2 | var react = require('gulp-react'); 3 | var sass = require('gulp-sass'); 4 | var source = require('vinyl-source-stream'); 5 | var connect = require('gulp-connect'); 6 | var reactify = require('reactify'); 7 | var watchify = require('watchify'); 8 | var browserify = require('browserify'); 9 | var autoprefixer = require('gulp-autoprefixer'); 10 | 11 | gulp.task('default', ['package']); 12 | 13 | gulp.task('package', function () { 14 | return gulp.src('lib/**/*.js*') 15 | .pipe(react()) 16 | .pipe(gulp.dest('pkg')); 17 | }); 18 | 19 | gulp.task('watch', ['default'], function () { 20 | gulp.watch('./lib/**/*', ['package']); 21 | }); 22 | 23 | gulp.task('example', ['example/stylesheets', 'example/app'], function () { 24 | gulp.watch('./example/*.scss', ['example/stylesheets']); 25 | 26 | return connect.server({ 27 | root: ['example/dist'], 28 | port: 8000, 29 | livereload: true 30 | }); 31 | }); 32 | 33 | gulp.task('example/app', function () { 34 | var bundler = watchify(browserify({ 35 | cache: {}, 36 | packageCache: {}, 37 | fullPaths: true, 38 | extensions: '.jsx' 39 | })); 40 | 41 | bundler.add('./example/app.jsx'); 42 | bundler.transform(reactify); 43 | 44 | bundler.on('update', rebundle); 45 | 46 | function rebundle () { 47 | console.log('rebundling'); 48 | return bundler.bundle() 49 | .on('error', function (err) { 50 | console.log(err.message); 51 | }) 52 | .pipe(source('main.js')) 53 | .pipe(gulp.dest('./example/dist/js')) 54 | .pipe(connect.reload()); 55 | } 56 | 57 | return rebundle(); 58 | }); 59 | 60 | gulp.task('example/stylesheets', function () { 61 | return gulp.src('./example/screen.scss') 62 | .pipe(sass({errLogToConsole: true})) 63 | .pipe(autoprefixer()) 64 | .pipe(gulp.dest('./example/dist/css')) 65 | .pipe(connect.reload()); 66 | }); 67 | -------------------------------------------------------------------------------- /lib/index.jsx: -------------------------------------------------------------------------------- 1 | module.exports = require('./views/manager'); 2 | -------------------------------------------------------------------------------- /lib/models/manager.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _ = require('lodash'); 4 | var signals = require('signals'); 5 | var Window = require('./window'); 6 | 7 | var INITIAL_INDEX = 1; 8 | 9 | var Manager = function (windows) { 10 | signals.convert(this); 11 | 12 | this._windows = {}; 13 | this._index = INITIAL_INDEX; 14 | this._active = false; 15 | 16 | if (_.isArray(windows)) { 17 | windows.forEach(this.add, this); 18 | } 19 | 20 | this._resetIndex(); 21 | }; 22 | 23 | _.extend(Manager.prototype, { 24 | 25 | 26 | /** 27 | * get a window by it's id 28 | * - id (string) : window id 29 | */ 30 | 31 | get: function (id) { 32 | return this._windows[id]; 33 | }, 34 | 35 | 36 | /** 37 | * check if a window exists in this manager 38 | * - window (window|string) 39 | */ 40 | 41 | has: function (window) { 42 | var id = _.isObject(window) ? window.id : window; 43 | return this._windows.hasOwnProperty(id); 44 | }, 45 | 46 | 47 | /** 48 | * add a window 49 | * - window (Window|object) 50 | */ 51 | 52 | add: function (window) { 53 | if (!(window instanceof Window)) { window = new Window(window); } 54 | window.manager = this; 55 | 56 | this._windows[window.id] = window; 57 | this.focus(window); 58 | 59 | window.on('change:open', function () { 60 | this.emit('change'); 61 | }, this); 62 | 63 | window.on('change', function () { 64 | this.emit('change:windows'); 65 | }, this); 66 | 67 | this.emit('add', window); 68 | this.emit('change'); 69 | 70 | return window; 71 | }, 72 | 73 | 74 | /** 75 | * remove a window 76 | * - window (Window|string) 77 | */ 78 | 79 | remove: function (window) { 80 | var id = _.isObject(window) ? window.id : window; 81 | window = this.get(id); 82 | 83 | if (! window) { 84 | throw new Error('Can not a window that it cannot find: ' + id); 85 | } 86 | 87 | delete this._windows[id]; 88 | 89 | this.emit('remove', window); 90 | this.emit('change'); 91 | 92 | return window; 93 | }, 94 | 95 | 96 | /** 97 | * open a window 98 | * - id (string) 99 | * - component (React) 100 | * - defaults (object) 101 | */ 102 | 103 | open: function (id, component, defaults) { 104 | if (! defaults) { defaults = {}; } 105 | defaults.id = id; 106 | 107 | var window = this.has(id) ? this.get(id) : this.add(defaults); 108 | window.setComponent(component); 109 | window.open(); 110 | this.focus(window); 111 | 112 | return window; 113 | }, 114 | 115 | 116 | /** 117 | * count how many windows are open 118 | * > int 119 | */ 120 | 121 | length: function () { 122 | return _.keys(this._windows).length; 123 | }, 124 | 125 | 126 | /** 127 | * focus on a window 128 | * - window (Window|string) 129 | */ 130 | 131 | focus: function (id) { 132 | var window = _.isObject(id) ? id : this.get(id); 133 | 134 | if (! window) { 135 | throw new Error('Can not focus on a window it cannot find: ' + id); 136 | } else if (window === this._active) { 137 | // this window already has focus 138 | return; 139 | } 140 | 141 | window.setIndex(this._index); 142 | this._index += 1; 143 | this._active = window; 144 | this.emit('change'); 145 | }, 146 | 147 | /** 148 | * get the active window 149 | */ 150 | 151 | active: function () { 152 | return this._active; 153 | }, 154 | 155 | 156 | /** 157 | * get all windows (open and closed) 158 | */ 159 | 160 | allWindows: function () { 161 | return _.values(this._windows); 162 | }, 163 | 164 | 165 | /** 166 | * get all open windows 167 | * > array 168 | */ 169 | 170 | openWindows: function () { 171 | return this.allWindows().filter(function (window) { 172 | return window.isOpen; 173 | }); 174 | }, 175 | 176 | 177 | /** 178 | * export as a standard JS array 179 | * > object 180 | */ 181 | 182 | toJSON: function () { 183 | return this.allWindows().map(function (window) { 184 | return window.toJSON(); 185 | }); 186 | }, 187 | 188 | 189 | /** 190 | * export as a JSON string 191 | * > string 192 | */ 193 | 194 | toString: function () { 195 | return JSON.stringify(this); 196 | }, 197 | 198 | 199 | /** 200 | * private: reset window index to 0 201 | */ 202 | 203 | _resetIndex: function () { 204 | this._index = INITIAL_INDEX; 205 | _.sortBy(this.allWindows(), 'index').forEach(function (window) { 206 | window.setIndex(this._index); 207 | this._index += 1; 208 | this._active = window; 209 | }, this); 210 | } 211 | 212 | }); 213 | 214 | module.exports = Manager; 215 | -------------------------------------------------------------------------------- /lib/models/window.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _ = require('lodash'); 4 | var signals = require('signals'); 5 | 6 | var INACTIVE = 0; 7 | var MOVE = 1; 8 | var RESIZE = 2; 9 | 10 | var Window = function (props) { 11 | signals.convert(this); 12 | 13 | _.extend(this, _.defaults(_.clone(props), this.defaults)); 14 | this.mode = INACTIVE; 15 | 16 | // JSON converts Infinity to null 17 | if (this.maxWidth === null) { this.maxWidth = Infinity; } 18 | if (this.maxHeight === null) { this.maxHeight = Infinity; } 19 | 20 | if (this.id === undefined) { 21 | throw new Error('All windows must have an id'); 22 | } 23 | }; 24 | 25 | _.extend(Window.prototype, { 26 | 27 | 28 | /** 29 | * defaults 30 | */ 31 | 32 | defaults: { 33 | id: undefined, 34 | x: 0, 35 | y: 0, 36 | index: 1, 37 | width: 0, 38 | height: 0, 39 | maxWidth: Infinity, 40 | minWidth: 0, 41 | maxHeight: Infinity, 42 | minHeight: 0, 43 | title: '', 44 | isOpen: true, 45 | component: undefined 46 | }, 47 | 48 | 49 | /** 50 | * set position of the window 51 | * - x (number) 52 | * - y (number) 53 | */ 54 | 55 | setPosition: function (x, y) { 56 | this.x = x; 57 | this.y = y; 58 | this.emit('change:position'); 59 | this.emit('change'); 60 | }, 61 | 62 | 63 | /** 64 | * resize the window 65 | * - width (number) 66 | * - height (number) 67 | */ 68 | 69 | setSize: function (width, height) { 70 | this.width = width; 71 | this.height = height; 72 | this.emit('change:size'); 73 | this.emit('change'); 74 | }, 75 | 76 | 77 | /** 78 | * set z-index of window 79 | * - index (int) 80 | */ 81 | 82 | setIndex: function (index) { 83 | this.index = index; 84 | this.emit('change:index'); 85 | this.emit('change'); 86 | }, 87 | 88 | 89 | /** 90 | * start moving the window 91 | * - x (number) : horizontal position of the mouse 92 | * - y (number) : vertical position of the mouse 93 | */ 94 | 95 | startMove: function (x, y) { 96 | this.mode = MOVE; 97 | this._offsetX = x - this.x; 98 | this._offsetY = y - this.y; 99 | }, 100 | 101 | 102 | /** 103 | * start resizing the window 104 | * - x (number) : horizontal position of the mouse 105 | * - y (number) : vertical position of the mouse 106 | */ 107 | 108 | startResize: function (x, y) { 109 | this.mode = RESIZE; 110 | this._quad = this._quadrant(x, y); 111 | this._startX = this.x; 112 | this._startY = this.y; 113 | this._startWidth = this.width; 114 | this._startHeight = this.height; 115 | this._originX = x; 116 | this._originY = y; 117 | }, 118 | 119 | 120 | /** 121 | * update a move/resize action 122 | * - x (number) : horizontal position of the mouse 123 | * - y (number) : vertical position of the mouse 124 | */ 125 | 126 | update: function (x, y) { 127 | if (this.mode === MOVE) { return this._move(x, y); } 128 | if (this.mode === RESIZE) { return this._resize(x, y); } 129 | }, 130 | 131 | 132 | /** 133 | * finish moving/resizing the window 134 | */ 135 | 136 | endChange: function () { 137 | if (this.mode === INACTIVE) { return; } 138 | this.mode = INACTIVE; 139 | 140 | if (this.mode === MOVE) { 141 | delete this._offsetX; 142 | delete this._offsetY; 143 | } 144 | 145 | else if (this.mode === RESIZE) { 146 | delete this._quad; 147 | delete this._startX; 148 | delete this._startY; 149 | delete this._startWidth; 150 | delete this._startHeight; 151 | delete this._originX; 152 | delete this._originY; 153 | this.emit('change:size'); 154 | } 155 | 156 | this.emit('change:position'); 157 | this.emit('change'); 158 | }, 159 | 160 | 161 | /** 162 | * open the window 163 | */ 164 | 165 | open: function () { 166 | if (this.isOpen) { return; } 167 | this.isOpen = true; 168 | this.emit('change:open'); 169 | this.emit('change'); 170 | }, 171 | 172 | 173 | /** 174 | * close the window 175 | */ 176 | 177 | close: function () { 178 | if (! this.isOpen) { return; } 179 | this.isOpen = false; 180 | this.emit('change:open'); 181 | this.emit('change'); 182 | }, 183 | 184 | 185 | /** 186 | * focus the window 187 | */ 188 | 189 | requestFocus: function () { 190 | if (! this.manager) { 191 | throw new Error('Cannot focus a window that is not being managed'); 192 | } 193 | this.manager.focus(this); 194 | }, 195 | 196 | 197 | /** 198 | * check if the window is focused 199 | */ 200 | 201 | isFocused: function () { 202 | if (! this.manager) { return false; } 203 | return this.manager.active() === this; 204 | }, 205 | 206 | 207 | /* 208 | * rename the window 209 | * - title (string) 210 | */ 211 | 212 | rename: function (title) { 213 | this.title = title; 214 | this.emit('change:title'); 215 | this.emit('change'); 216 | }, 217 | 218 | 219 | /* 220 | * set component 221 | * - component (React) 222 | */ 223 | 224 | setComponent: function (component) { 225 | this.component = component; 226 | this.emit('change:component'); 227 | this.emit('change'); 228 | }, 229 | 230 | 231 | /** 232 | * export model as json 233 | */ 234 | 235 | toJSON: function () { 236 | return { 237 | id: this.id, 238 | x: this.x, 239 | y: this.y, 240 | index: this.index, 241 | width: this.width, 242 | height: this.height, 243 | maxWidth: this.maxWidth, 244 | minWidth: this.minWidth, 245 | maxHeight: this.maxHeight, 246 | minHeight: this.minHeight, 247 | title: this.title, 248 | isOpen: this.isOpen 249 | }; 250 | }, 251 | 252 | 253 | 254 | /** 255 | * private 256 | * move the window to a point 257 | * - x (number) : horizontal position of the mouse 258 | * - y (number) : vertical position of the mouse 259 | */ 260 | 261 | _move: function (x, y) { 262 | this.x = x - this._offsetX; 263 | this.y = y - this._offsetY; 264 | }, 265 | 266 | 267 | /** 268 | * private 269 | * resize the window by an amount 270 | * - x (number) : horizontal position of the mouse 271 | * - y (number) : vertical position of the mouse 272 | */ 273 | 274 | _resize: function (x, y) { 275 | var deltaX = x - this._originX; 276 | var deltaY = y - this._originY; 277 | 278 | var finalWidth = this._startWidth + (this._quad.left ? deltaX * -1 : deltaX); 279 | var finalHeight = this._startHeight + (this._quad.top ? deltaY * -1 : deltaY); 280 | 281 | if (finalWidth > this.maxWidth ) { 282 | deltaX = this.maxWidth - this._startWidth; 283 | if (this._quad.left) { deltaX *= -1; } 284 | } else if (finalWidth < this.minWidth) { 285 | deltaX = this.minWidth - this._startWidth; 286 | if (this._quad.left) { deltaX *= -1; } 287 | } 288 | 289 | if (finalHeight > this.maxHeight) { 290 | deltaY = this.maxHeight - this._startHeight; 291 | if (this._quad.top) { deltaY *= -1; } 292 | } else if (finalHeight < this.minHeight) { 293 | deltaY = this.minHeight - this._startHeight; 294 | if (this._quad.top) { deltaY *= -1; } 295 | } 296 | 297 | if (this._quad.left) { 298 | this.x = this._startX + deltaX; 299 | this.width = this._startWidth - deltaX; 300 | } else { 301 | this.width = this._startWidth + deltaX; 302 | } 303 | 304 | if (this._quad.top) { 305 | this.y = this._startY + deltaY; 306 | this.height = this._startHeight - deltaY; 307 | } else { 308 | this.height = this._startHeight + deltaY; 309 | } 310 | }, 311 | 312 | 313 | /** 314 | * private 315 | * find which quadrant of the window the mouse is 316 | * - x (number) : horizontal position of the mouse 317 | * - y (number) : vertical position of the mouse 318 | */ 319 | 320 | _quadrant: function (x, y) { 321 | return { 322 | top: y < this.y + (this.height / 2), 323 | left: x < this.x + (this.width / 2) 324 | }; 325 | } 326 | 327 | }); 328 | 329 | module.exports = Window; 330 | -------------------------------------------------------------------------------- /lib/views/manager.jsx: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _ = require('lodash'); 4 | var $ = require('jquery'); 5 | var React = require('react'); 6 | var CSSTransitionGroup = require('react/addons').addons.CSSTransitionGroup; 7 | 8 | var WindowModel = require('../models/window'); 9 | var ManagerModel = require('../models/manager'); 10 | var Window = require('./window'); 11 | 12 | var Manager = React.createClass({ 13 | 14 | statics: { 15 | Manager: ManagerModel, 16 | Window: WindowModel 17 | }, 18 | 19 | propTypes: { 20 | manager: React.PropTypes.instanceOf(ManagerModel).isRequired 21 | }, 22 | 23 | componentDidMount: function () { 24 | this.manager = this.props.manager; 25 | this.manager.on('change', this.forceUpdate, this); 26 | 27 | var el = $(this.getDOMNode()); 28 | this.setState({ offset: el.offset() }); 29 | }, 30 | 31 | componentWillUnmount: function () { 32 | this.manager.off('change', this.forceUpdate); 33 | }, 34 | 35 | getInitialState: function () { 36 | return { 37 | offset: { 38 | top: 0, 39 | left: 0 40 | } 41 | }; 42 | }, 43 | 44 | render: function () { 45 | 46 | var windows = this.props.manager.openWindows().map(function (window) { 47 | return new Window({ 48 | key: window.id, 49 | offset: this.state.offset, 50 | window: window, 51 | }); 52 | }, this); 53 | 54 | return ( 55 | /* jshint ignore: start */ 56 |
57 |
{windows}
58 |
59 | /* jshint ignore: end */ 60 | ); 61 | } 62 | 63 | }); 64 | 65 | module.exports = Manager; 66 | -------------------------------------------------------------------------------- /lib/views/window.jsx: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _ = require('lodash'); 4 | var $ = require('jquery'); 5 | var React = require('react'); 6 | var classSet = require('react/addons').addons.classSet; 7 | 8 | var WindowModel = require('../models/window'); 9 | 10 | var INACTIVE = 0; 11 | var MOVE = 1; 12 | var RESIZE = 2; 13 | 14 | var Window = React.createClass({ 15 | 16 | propTypes: { 17 | window: React.PropTypes.instanceOf(WindowModel).isRequired, 18 | offset: React.PropTypes.object.isRequired 19 | }, 20 | 21 | componentWillMount: function () { 22 | this.window = this.props.window; 23 | }, 24 | 25 | componentDidMount: function () { 26 | this.window.on('change', this.forceUpdate, this); 27 | document.addEventListener('mousemove', this.handleMouseMove); 28 | document.addEventListener('mouseup', this.handleMouseUp); 29 | }, 30 | 31 | componentWillUnmount: function () { 32 | this.window.off('change', this.forceUpdate); 33 | document.removeEventListener('mousemove', this.handleMouseMove); 34 | document.removeEventListener('mouseup', this.handleMouseUp); 35 | }, 36 | 37 | quickUpdate: function () { 38 | var self = this; 39 | requestAnimationFrame(function () { 40 | var el = self.getDOMNode(); 41 | el.style.width = self.window.width + 'px'; 42 | el.style.height = self.window.height + 'px'; 43 | el.style.top = self.window.y + 'px'; 44 | el.style.left = self.window.x + 'px'; 45 | }); 46 | }, 47 | 48 | preventDefault: function (e) { 49 | e.preventDefault(); 50 | return false; 51 | }, 52 | 53 | handlePropagation: function (e) { 54 | if (!(e.ctrlKey || e.metaKey || e.altKey || e.button !== 0)){ 55 | this.focus(); 56 | e.stopPropagation(); 57 | } 58 | }, 59 | 60 | handleResize: function (e) { 61 | this.focus(); 62 | var mouse = this.convertPoints(e); 63 | this.window.startResize(mouse.x, mouse.y); 64 | e.stopPropagation(); 65 | }, 66 | 67 | handleMove: function (e) { 68 | e.preventDefault(); 69 | this.focus(); 70 | var mouse = this.convertPoints(e); 71 | this.window.startMove(mouse.x, mouse.y); 72 | this.refs.content.getDOMNode().children[0].focus(); 73 | }, 74 | 75 | handleMouseMove: function (e) { 76 | if (this.window.mode === INACTIVE) { return true; } 77 | var mouse = this.convertPoints(e); 78 | this.window.update(mouse.x, mouse.y); 79 | this.quickUpdate(); 80 | }, 81 | 82 | handleMouseUp: function () { 83 | this.window.endChange(); 84 | }, 85 | 86 | focus: function () { 87 | this.window.requestFocus(); 88 | }, 89 | 90 | close: function () { 91 | this.window.requestFocus(); 92 | this.window.close(); 93 | }, 94 | 95 | convertPoints: function (e) { 96 | return { 97 | x: e.clientX - this.props.offset.left, 98 | y: e.clientY - this.props.offset.top 99 | }; 100 | }, 101 | 102 | render: function () { 103 | var classes = classSet({ 104 | window: true, 105 | active: this.window.isFocused() 106 | }); 107 | 108 | var styles = { 109 | top: this.window.y, 110 | left: this.window.x, 111 | width: this.window.width, 112 | height: this.window.height, 113 | zIndex: this.window.index 114 | }; 115 | 116 | return ( 117 | /* jshint ignore: start */ 118 |
119 |
120 |
{this.window.title}
121 |
122 |
123 |
124 | {this.window.component} 125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 | /* jshint ignore: end */ 133 | ); 134 | } 135 | 136 | }); 137 | 138 | module.exports = Window; 139 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reactwm", 3 | "version": "0.1.3", 4 | "description": "A minimal window manager built using React", 5 | "main": "./pkg/index.js", 6 | "scripts": { 7 | "test": "mocha -R spec test/**/*.js -b", 8 | "start": "gulp example/connect" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/stayradiated/react" 13 | }, 14 | "author": "George Czabania", 15 | "license": "MIT", 16 | "bugs": { 17 | "url": "https://github.com/stayradiated/react/issues" 18 | }, 19 | "homepage": "https://github.com/stayradiated/react", 20 | "devDependencies": { 21 | "browserify": "^5.11.0", 22 | "chai": "^1.9.1", 23 | "gulp": "^3.8.7", 24 | "gulp-autoprefixer": "0.0.10", 25 | "gulp-connect": "^2.0.6", 26 | "gulp-react": "^1.0.0", 27 | "gulp-sass": "^0.7.2", 28 | "mocha": "^1.21.4", 29 | "reactify": "^0.14.0", 30 | "sinon": "^1.10.3", 31 | "vinyl-source-stream": "^0.1.1", 32 | "watchify": "^1.0.1" 33 | }, 34 | "dependencies": { 35 | "jquery": "^2.1.1", 36 | "lodash": "^2.4.1", 37 | "react": "^0.11.1", 38 | "signals": "stayradiated/signals" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /test/models/manager.js: -------------------------------------------------------------------------------- 1 | var sinon = require('sinon'); 2 | var assert = require('chai').assert; 3 | var Window = require('../../lib/models/window'); 4 | var Manager = require('../../lib/models/manager'); 5 | 6 | describe('manager', function () { 7 | 8 | var manager; 9 | 10 | beforeEach(function () { 11 | manager = new Manager(); 12 | }); 13 | 14 | describe('.get', function () { 15 | 16 | it('should get a window by its id', function () { 17 | var win1 = new Window({ id: 'a' }); 18 | var win2 = new Window({ id: 'b' }); 19 | var win3 = new Window({ id: 'c' }); 20 | 21 | manager.add(win1); 22 | manager.add(win2); 23 | manager.add(win3); 24 | 25 | assert.equal(manager.get('a'), win1); 26 | assert.equal(manager.get('b'), win2); 27 | assert.equal(manager.get('c'), win3); 28 | }); 29 | 30 | }); 31 | 32 | describe('.has', function () { 33 | 34 | it('should check if a window exists in the manager', function () { 35 | var window = new Window({ id: 0 }); 36 | 37 | assert.isFalse(manager.has(window)); 38 | manager.add(window); 39 | assert.isTrue(manager.has(window)); 40 | }); 41 | 42 | }); 43 | 44 | describe('.add', function () { 45 | 46 | it('should add a window', function () { 47 | var window = new Window({ id: 'a' }); 48 | assert.equal(manager.add(window), window); 49 | assert.equal(manager.length(), 1); 50 | assert.deepEqual(manager.allWindows(), [window]); 51 | }); 52 | 53 | it('should convert object to window instance', function () { 54 | var window = { id: 'custom', title: 'my title' }; 55 | window = manager.add(window); 56 | assert(window instanceof Window); 57 | assert.equal(manager.length(), 1); 58 | assert.equal(manager.get('custom'), window); 59 | }); 60 | 61 | }); 62 | 63 | describe('.remove', function () { 64 | 65 | it('should remove a window', function () { 66 | var window = new Window({ id: 0 }); 67 | manager.add(window); 68 | assert.equal(manager.length(), 1); 69 | manager.remove(window); 70 | assert.equal(manager.length(), 0); 71 | }); 72 | 73 | it('should remove a window by its id', function () { 74 | var window = {id: 0}; 75 | manager.add(window); 76 | assert.equal(manager.length(), 1); 77 | manager.remove(0); 78 | assert.equal(manager.length(), 0); 79 | }); 80 | 81 | }); 82 | 83 | describe('.open', function () { 84 | 85 | it('should only add a window once', function () { 86 | var size = 10; 87 | var component = '
'; 88 | var props = { id: 20, x: size, y: size, width: size, height: size }; 89 | 90 | var window = manager.open(component, props); 91 | 92 | assert(manager.has(window)); 93 | assert.equal(manager.length(), 1); 94 | 95 | assert.equal(manager.open(component,props), window); 96 | assert.equal(manager.length(), 1); 97 | }); 98 | 99 | }); 100 | 101 | describe('.focus', function () { 102 | 103 | it('should increase z-index', function () { 104 | var win1 = new Window({ id: 1 }); 105 | var win2 = new Window({ id: 2 }); 106 | var win3 = new Window({ id: 3 }); 107 | 108 | manager.add(win1); 109 | manager.add(win2); 110 | manager.add(win3); 111 | assert.deepEqual(manager._active, win3); 112 | 113 | manager.focus(win1); 114 | assert.deepEqual(manager._active, win1); 115 | 116 | manager.focus(win2); 117 | assert.deepEqual(manager._active, win2); 118 | 119 | manager.focus(win3); 120 | assert.deepEqual(manager._active, win3); 121 | }); 122 | 123 | }); 124 | 125 | describe('.toJSON', function () { 126 | 127 | it('should export to a JS array', function () { 128 | var props = { 129 | id: 'my-id', 130 | x: 200, 131 | y: 300, 132 | width: 100, 133 | height: 50, 134 | title: 'My amazing window', 135 | isOpen: true 136 | }; 137 | 138 | var window = manager.add(props); 139 | assert.deepEqual(manager.toJSON(), [window.toJSON()]); 140 | }); 141 | 142 | }); 143 | 144 | }); 145 | -------------------------------------------------------------------------------- /test/models/window.js: -------------------------------------------------------------------------------- 1 | var assert = require('chai').assert; 2 | var sinon = require('sinon'); 3 | var Window = require('../../lib/models/window'); 4 | 5 | describe('window', function () { 6 | var window; 7 | 8 | beforeEach(function () { 9 | window = new Window({ 10 | id: 0, 11 | x: 0, 12 | y: 0, 13 | width: 100, 14 | height: 100, 15 | title: 'title' 16 | }); 17 | }); 18 | 19 | describe('move', function () { 20 | 21 | it('should move the window to a point', function () { 22 | var window = new Window({ id: 0 }); 23 | assert.equal(window.x, 0); 24 | assert.equal(window.y, 0); 25 | 26 | window.startMove(0, 0); 27 | 28 | var x = 200; 29 | var y = 300; 30 | 31 | window.update(x, y); 32 | assert.equal(window.x, x); 33 | assert.equal(window.y, y); 34 | 35 | window.endChange(); 36 | }); 37 | 38 | }); 39 | 40 | describe('._quadrant', function () { 41 | 42 | it('top left', function () { 43 | assert.deepEqual(window._quadrant(1, 1), { 44 | top: true, 45 | left: true 46 | }); 47 | }); 48 | 49 | it('top right', function () { 50 | assert.deepEqual(window._quadrant(99, 1), { 51 | top: true, 52 | left: false 53 | }); 54 | }); 55 | 56 | it('bottom left', function () { 57 | assert.deepEqual(window._quadrant(1, 99), { 58 | top: false, 59 | left: true 60 | }); 61 | }); 62 | 63 | it('bottom right', function () { 64 | assert.deepEqual(window._quadrant(99, 99), { 65 | top: false, 66 | left: false 67 | }); 68 | }); 69 | 70 | }); 71 | 72 | describe('.resize', function () { 73 | var start = 100; 74 | var change = 20; 75 | 76 | beforeEach(function () { 77 | window.setPosition(start, start); 78 | window.setSize(start, start); 79 | }); 80 | 81 | afterEach(function () { 82 | window.endChange(); 83 | }); 84 | 85 | describe('left', function () { 86 | 87 | beforeEach(function () { 88 | window.startResize(start, 0); 89 | }); 90 | 91 | it('in', function () { 92 | window.update(start - change, 0); 93 | assert.equal(window.x, start - change); 94 | assert.equal(window.width, start + change); 95 | }); 96 | 97 | it('out', function () { 98 | window.update(start + change, 0); 99 | assert.equal(window.x, start + change); 100 | assert.equal(window.width, start - change); 101 | }); 102 | }); 103 | 104 | describe('top', function () { 105 | 106 | beforeEach(function () { 107 | window.startResize(0, start); 108 | }); 109 | 110 | it('in', function () { 111 | window.update(0, start - change); 112 | assert.equal(window.y, start - change); 113 | assert.equal(window.height, start + change); 114 | }); 115 | 116 | it('out', function () { 117 | window.update(0, start + change); 118 | assert.equal(window.y, start + change); 119 | assert.equal(window.height, start - change); 120 | }); 121 | }); 122 | 123 | describe('right', function () { 124 | 125 | beforeEach(function () { 126 | window.startResize(start * 2, 0); 127 | }); 128 | 129 | it('in', function () { 130 | window.update(start * 2 - change, 0); 131 | assert.equal(window.x, start); 132 | assert.equal(window.width, start - change); 133 | }); 134 | 135 | it('out', function () { 136 | window.update(start * 2 + change, 0); 137 | assert.equal(window.x, start); 138 | assert.equal(window.width, start + change); 139 | }); 140 | }); 141 | 142 | describe('bottom', function () { 143 | 144 | beforeEach(function () { 145 | window.startResize(0, start * 2); 146 | }); 147 | 148 | it('in', function () { 149 | window.update(0, start * 2 - change); 150 | assert.equal(window.y, start); 151 | assert.equal(window.height, start - change); 152 | }); 153 | 154 | it('out', function () { 155 | window.update(0, start * 2 + change); 156 | assert.equal(window.y, start); 157 | assert.equal(window.height, start + change); 158 | }); 159 | }); 160 | 161 | }); 162 | 163 | describe('.open', function () { 164 | }); 165 | 166 | describe('.close', function () { 167 | 168 | it('should close the window', function () { 169 | var window = new Window({ id: 0 }); 170 | assert.equal(window.isOpen, true); 171 | 172 | window.close(); 173 | assert.equal(window.isOpen, false); 174 | }); 175 | 176 | }); 177 | 178 | describe('.rename', function () { 179 | 180 | it('should rename the window', function () { 181 | var window = new Window({ id: 0 }); 182 | assert.equal(window.title, ''); 183 | 184 | var title = 'My custom window title'; 185 | 186 | window.rename(title); 187 | assert.equal(window.title, title); 188 | }); 189 | 190 | }); 191 | 192 | describe('.toJSON', function () { 193 | 194 | it('should export to a standard JS object', function () { 195 | var props = { 196 | id: 'window-1', 197 | x: 20, 198 | y: 30, 199 | index: 1, 200 | width: 200, 201 | height: 400, 202 | maxWidth: Infinity, 203 | minWidth: 0, 204 | maxHeight: Infinity, 205 | minHeight: 0, 206 | title: 'Test', 207 | isOpen: true 208 | }; 209 | 210 | var window = new Window(props); 211 | assert.deepEqual(window.toJSON(), props); 212 | }); 213 | 214 | }); 215 | 216 | describe('.onChange', function () { 217 | 218 | var window, spy; 219 | 220 | beforeEach(function () { 221 | window = new Window({ id: 0 }); 222 | spy = sinon.spy(); 223 | window.on('change', spy); 224 | }); 225 | 226 | it('should trigger on setSize', function () { 227 | window.setSize(); 228 | assert(spy.calledOnce); 229 | }); 230 | 231 | it('should trigger on setPosition', function () { 232 | window.setPosition(); 233 | assert(spy.calledOnce); 234 | }); 235 | 236 | it('should trigger on move', function () { 237 | window.startMove(); 238 | window.update(0, 0); 239 | window.endChange(); 240 | assert(spy.calledOnce); 241 | }); 242 | 243 | it('should trigger on resize', function () { 244 | window.startResize(0, 0); 245 | window.update(0, 0); 246 | window.endChange(); 247 | assert(spy.calledOnce); 248 | }); 249 | 250 | it('should trigger on open', function () { 251 | window.open(); 252 | assert(! spy.called); 253 | window.close(); 254 | assert(spy.calledOnce); 255 | window.open(); 256 | assert(spy.called); 257 | }); 258 | 259 | it('should trigger on close', function () { 260 | window.close(); 261 | assert(spy.calledOnce); 262 | window.close(); 263 | assert(spy.calledOnce); 264 | }); 265 | 266 | it('should trigger on rename', function () { 267 | window.rename('new name'); 268 | assert(spy.calledOnce); 269 | }); 270 | 271 | }); 272 | 273 | }); 274 | --------------------------------------------------------------------------------