├── public ├── .gitignore ├── images │ └── logo-square.png ├── icons │ ├── livecalc.svg │ └── livecalc-source.svg ├── logo │ └── livecalc.svg └── main.js ├── sass ├── dashboard.scss ├── livecalc-icons.scss ├── account.scss ├── signup.scss ├── palette.scss ├── sidebar.scss ├── landing.scss ├── pricing.scss ├── chat.scss ├── header.scss ├── base.scss ├── livecalc.scss └── style.scss ├── views ├── sidebar.pug ├── palette-trigonometry.pug ├── dashboard.pug ├── login.pug ├── header.pug ├── palette-basics.pug ├── signup.pug ├── templates.pug ├── base.pug ├── palette-greek-letters.pug ├── landing.pug ├── chars.txt ├── page-content.pug ├── account.pug ├── livecalc-templates.pug ├── doc.pug └── pricing.pug ├── tokens.js ├── user ├── cookie_utils.js └── user_pages.js ├── chat_db.js ├── package.json ├── README.md ├── stats.js ├── Makefile ├── marketing └── marketing.js ├── sheet_counter.js ├── Users.md ├── .gitignore ├── sheet_db.js ├── sheet_model.js ├── user_cache.js ├── user_db.js ├── index.js ├── cache_user_model.js ├── livecalc.js └── LICENSE /public/.gitignore: -------------------------------------------------------------------------------- 1 | lib/** -------------------------------------------------------------------------------- /public/images/logo-square.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gmx/livecalc.xyz/master/public/images/logo-square.png -------------------------------------------------------------------------------- /sass/dashboard.scss: -------------------------------------------------------------------------------- 1 | .dashboard{ 2 | padding:30px; 3 | background:#fff; 4 | margin:20px; 5 | } 6 | 7 | a.account-settings-link{ 8 | margin-left:30px; 9 | } -------------------------------------------------------------------------------- /sass/livecalc-icons.scss: -------------------------------------------------------------------------------- 1 | .icon.delete-icon{ 2 | background:url(../icons/livecalc.svg); 3 | width:20px; 4 | height:20px; 5 | background-position:-20px 0px; 6 | } -------------------------------------------------------------------------------- /views/sidebar.pug: -------------------------------------------------------------------------------- 1 | .palette 2 | .sidebar-header.text-center 3 | h3 4 | | Palette 5 | .content 6 | include ./palette-basics.pug 7 | include ./palette-trigonometry.pug 8 | include ./palette-greek-letters.pug 9 | .palette-bottom-spacer 10 | livechat 11 | -------------------------------------------------------------------------------- /tokens.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | 3 | /* 4 | Thank you, Stack Overflow: 5 | http://stackoverflow.com/questions/8855687/secure-random-token-in-node-jse 6 | */ 7 | module.exports.generate_token = function(num){ 8 | var num = num || 10; 9 | return require('crypto').randomBytes(num).toString('hex'); 10 | } 11 | -------------------------------------------------------------------------------- /sass/account.scss: -------------------------------------------------------------------------------- 1 | .account{ 2 | background:#fff; 3 | } 4 | 5 | .account-content{ 6 | margin: 20px; 7 | padding-top:20px; 8 | } 9 | 10 | .account fieldset{ 11 | border: none; 12 | padding:0; 13 | } 14 | 15 | .account button[type='submit']{ 16 | margin-top:10px; 17 | margin-bottom:10px; 18 | } 19 | 20 | .account h4{ 21 | margin-top: 10px; 22 | margin-bottom: 10px; 23 | } -------------------------------------------------------------------------------- /sass/signup.scss: -------------------------------------------------------------------------------- 1 | .why-account{ 2 | max-width:700px; 3 | margin:auto; 4 | font-size:120%; 5 | font-weight:300; 6 | } 7 | 8 | .auth{ 9 | max-width:800px; 10 | background:#fff; 11 | margin:auto; 12 | } 13 | 14 | .auth fieldset{ 15 | border:none; 16 | padding:10px 0px; 17 | } 18 | 19 | .auth input{ 20 | width:100%; 21 | } 22 | 23 | .auth .login-block{ 24 | padding:20px; 25 | } 26 | 27 | .go-to-signup{ 28 | margin-left:30px; 29 | } -------------------------------------------------------------------------------- /user/cookie_utils.js: -------------------------------------------------------------------------------- 1 | var cookie = require('cookie'); 2 | 3 | module.exports = {}; 4 | 5 | module.exports.cookie_send_id = cookie_send_id; 6 | 7 | function cookie_send_id(res, id){ 8 | res.setHeader( 9 | 'Set-Cookie', 10 | cookie.serialize('session_id', id, { 11 | httpOnly: true, 12 | maxAge: 60 * 60 * 24 // 1 days 13 | })); 14 | } 15 | 16 | module.exports.cookie_get_id = cookie_get_id; 17 | 18 | function cookie_get_id(req){ 19 | return cookie.parse(req.headers.cookie || '').session_id; 20 | } 21 | -------------------------------------------------------------------------------- /views/palette-trigonometry.pug: -------------------------------------------------------------------------------- 1 | .palette-trigonometry 2 | h4 Trigonometry 3 | p In degree 4 | button(data-replace-var="x") sin(x deg) 5 | button(data-replace-var="x") cos(x deg) 6 | button(data-replace-var="x") tan(x deg) 7 | p In radian 8 | button(data-replace-var="x") sin(x) 9 | button(data-replace-var="x") cos(x) 10 | button(data-replace-var="x") tan(x) 11 | 12 | p Inverse trigonometric functions 13 | 14 | button(data-replace-var="x") asin(x) 15 | button(data-replace-var="x") acos(x) 16 | button(data-replace-var="x") atan(x) 17 | -------------------------------------------------------------------------------- /sass/palette.scss: -------------------------------------------------------------------------------- 1 | 2 | .palette{ 3 | background:#eee; 4 | } 5 | 6 | .palette .content{ 7 | margin-left:10px; 8 | max-height:100%; 9 | overflow-y: scroll; 10 | } 11 | 12 | .palette-bottom-spacer{ 13 | height:60px; 14 | } 15 | 16 | .palette button{ 17 | margin-top:5px; 18 | margin-bottom:0px; 19 | margin-right:2px; 20 | } 21 | 22 | .palette-greek-letters button{ 23 | width:30px; 24 | } 25 | 26 | .palette h4{ 27 | line-height:20px; 28 | margin:0; 29 | margin-top:10px; 30 | padding:4px; 31 | } 32 | 33 | .palette p{ 34 | line-height:15px; 35 | margin-top:8px; 36 | margin-bottom:0; 37 | } -------------------------------------------------------------------------------- /views/dashboard.pug: -------------------------------------------------------------------------------- 1 | .dashboard-content 2 | col-md-6 3 | h3 Dashboard 4 | h4 Recently visited sheets 5 | if recent_sheets.length == 0 6 | p (No recently visited sheets) 7 | .recent-sheet-list 8 | ul 9 | for sheet in recent_sheets 10 | -url = "/sheet/"+sheet 11 | li 12 | a(href=url)= url 13 | p 14 | a(href="/new") 15 | button 16 | | Create a new sheet 17 | 18 | col-md-6 19 | h4 Account Settings 20 | p 21 | | Change your username, password, email address & more : 22 | a(href="/account").account-settings-link 23 | button Account Settings 24 | -------------------------------------------------------------------------------- /chat_db.js: -------------------------------------------------------------------------------- 1 | var redis = require("redis"); 2 | 3 | var client = redis.createClient(); 4 | 5 | module.exports = {}; 6 | 7 | module.exports.add_message = function(id, data){ 8 | var data = JSON.stringify(data); 9 | client.rpush(["livechat:"+id, data], function(err, reply){ 10 | if(err != null){ 11 | console.log(err); 12 | } 13 | }); 14 | }; 15 | 16 | module.exports.get_conv = function(id, callback){ 17 | /* get 100 last messages */ 18 | client.lrange("livechat:"+id, -100, -1, function(err, reply){ 19 | if(err != null){ 20 | console.log(err); 21 | } 22 | callback(reply); 23 | }); 24 | }; 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "livecalc.xyz", 3 | "version": "0.0.13", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "body-parser": "^1.15.1", 13 | "cookie": "^0.3.1", 14 | "cookie-session": "^2.0.0-alpha.1", 15 | "crypto": "0.0.3", 16 | "deepcopy": "^0.6.3", 17 | "email-validator": "^1.0.4", 18 | "express": "^4.14.0", 19 | "mongoose": "^4.5.3", 20 | "multer": "^1.1.0", 21 | "node-sass-middleware": "^0.9.8", 22 | "password-hash": "^1.2.2", 23 | "pug": "^2.0.0-beta3", 24 | "redis": "^2.6.2", 25 | "socket.io": "^1.4.6" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /views/login.pug: -------------------------------------------------------------------------------- 1 | h1.text-center Login 2 | .auth.login-form 3 | col-md-6 4 | .basic-auth.login-block 5 | p 6 | | Don't have an account? 7 | a(href="/signup").go-to-signup 8 | | sign up! 9 | form(action="/login", method="post") 10 | fieldset 11 | label Your email address 12 | br 13 | input(type="email", name="email", placeholder="email@email.com") 14 | fieldset 15 | label Password 16 | br 17 | input(type="password", name="password", placeholder="Password") 18 | br 19 | .text-center 20 | button(type="submit" value="submit") 21 | | Login 22 | col-md-6 23 | .facebook-login.login-block 24 | //h3 25 | // | Login with Facebook 26 | //p Not yet 27 | -------------------------------------------------------------------------------- /views/header.pug: -------------------------------------------------------------------------------- 1 | header 2 | col-md-8 3 | h1.inline-block.mobile-center 4 | a(href='https://livecalc.xyz') livecalc.xyz 5 | p.inline-block.text-left.mobile-center 6 | | Github 7 | a(href='https://github.com/antoineMoPa/livecalc', 8 | target='_blank') project 9 | | | 10 | | Version 0.0.13 11 | col-md-4.text-right.mobile-center.links 12 | a.new-sheet-button(href='/new', target='_blank') 13 | button New Sheet 14 | nav 15 | ul 16 | li 17 | a.pricing-link(href='/pricing', target='_blank') 18 | | Pricing 19 | 20 | if !logged_in 21 | li 22 | a.login(href='/login') 23 | | Log in 24 | 25 | else 26 | li 27 | a.dashboard-link(href='/dashboard') 28 | | Dashboard 29 | li 30 | a(href="/logout") Log out 31 | -------------------------------------------------------------------------------- /sass/sidebar.scss: -------------------------------------------------------------------------------- 1 | .sticky-sidebar{ 2 | position:fixed; 3 | background:#f2f2f2; 4 | height:100%; 5 | top:0; 6 | right:0; 7 | bottom:0; 8 | z-index:1000; 9 | box-shadow:0 0 3px rgba(0,0,0,0.15); 10 | } 11 | 12 | .sidebar-header{ 13 | background:$color_6; 14 | font-size:12px; 15 | padding:3px; 16 | text-align:left; 17 | } 18 | 19 | .sidebar-header h3{ 20 | color:#fff; 21 | } 22 | 23 | @media all and (min-width:768px){ 24 | .in-sheet header, 25 | .in-sheet nav{ 26 | width: 75%; 27 | } 28 | } 29 | 30 | @media (max-width:768px){ 31 | .sticky-sidebar{ 32 | display:none; 33 | } 34 | } 35 | 36 | .sidebar-resize-header{ 37 | cursor: ns-resize; 38 | } 39 | 40 | livechat{ 41 | height:33%; 42 | } 43 | 44 | .palette{ 45 | height:66%; 46 | } 47 | 48 | @import "palette.scss"; 49 | 50 | -------------------------------------------------------------------------------- /sass/landing.scss: -------------------------------------------------------------------------------- 1 | /* landing page */ 2 | 3 | body.page-landing{ 4 | background:$color_3; 5 | } 6 | 7 | .landing{ 8 | max-width:100%; 9 | word-wrap: break-word; 10 | } 11 | 12 | .landing h1{ 13 | line-height:80px; 14 | color:#fff; 15 | font-size:30px; 16 | font-family: 'Fira Sans'; 17 | font-weight:300; 18 | } 19 | 20 | .landing p{ 21 | line-height:150%; 22 | color:#fff; 23 | font-size:24px; 24 | font-family:sans-serif; 25 | font-family: 'Fira Sans'; 26 | font-weight:300; 27 | } 28 | 29 | .landing a{ 30 | color: $color_2; 31 | } 32 | 33 | .features{ 34 | background:#eee; 35 | color:#333; 36 | margin-left:-20px; 37 | margin-right:-25px; 38 | padding:20px; 39 | border-bottom:5px solid $body_bg; 40 | } 41 | 42 | .features h3{ 43 | text-shadow:none; 44 | } 45 | 46 | @media (min-width:1000px){ 47 | .features .block-center{ 48 | width:40%; 49 | margin:auto; 50 | } 51 | } 52 | 53 | -------------------------------------------------------------------------------- /views/palette-basics.pug: -------------------------------------------------------------------------------- 1 | .palette-basics 2 | h4 Basics 3 | button(title='Last result') ans 4 | button(title='Add', 5 | data-wrap-before="(", 6 | data-wrap-after="+[[cursor]])", 7 | ) + 8 | button(title='Substract', 9 | data-wrap-before="(", 10 | data-wrap-after="-[[cursor]])", 11 | ) - 12 | button(title='Multiply', 13 | data-wrap-before="(", 14 | data-wrap-after=")*[[cursor]]", 15 | ) * 16 | button(title='Divide', 17 | data-wrap-before="(", 18 | data-wrap-after=")*[[cursor]]", 19 | ) / 20 | button(title='Exponent', 21 | data-wrap-before="(", 22 | data-wrap-after=")^[[cursor]]", 23 | ) ^ 24 | button(title='Square root', 25 | data-wrap-before="sqrt(", 26 | data-wrap-after="[[cursor]])", 27 | data-no-sel="sqrt([[cursor]])", 28 | ) sqrt 29 | button(title='π, The math constant Pi', 30 | data-no-sel="π[[cursor]]", 31 | ) π 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Code Status: No major problem. 2 | 3 | # livecalc.xyz 4 | 5 | livecalc.xyz is a multi-user math web app. The math is parsed & computed in the browser by [math.js](http://mathjs.org/). 6 | 7 | [Live demo](https://www.livecalc.xyz/sheet/demo) 8 | 9 | # Example 10 | 11 | sin(45 deg) => 0.7071067811865475 12 | 13 | # Code 14 | 15 | A node.js backend with socket.io is used for synchronisation of sheets between multiple users. 16 | The client UI is mostly written in vanilla JS. No jQuery, no angular, no react, no whatever (for now). 17 | 18 | # Install 19 | 20 | Clone: 21 | 22 | git clone https://github.com/antoineMoPa/livecalc.git 23 | 24 | Backend dependencies: 25 | 26 | npm install 27 | 28 | I was too hipster for browserify. So I decided to create a makefile to download frontend dependencies: 29 | 30 | make download 31 | 32 | If you want to use the fira firefox font: 33 | 34 | make fira 35 | 36 | Run 37 | 38 | nodejs index.js 39 | 40 | You can now visit [http://127.0.0.1:3000](http://127.0.0.1:3000). 41 | 42 | # Contributing 43 | 44 | You can submit issues. 45 | 46 | You can also clone and code then send a pull request. 47 | -------------------------------------------------------------------------------- /views/signup.pug: -------------------------------------------------------------------------------- 1 | h1.text-center Create your account 2 | 3 | .why-account 4 | // hide for now 5 | //ul 6 | li Keep track of your sheets, lock and unlock them 7 | li Store up to 5 private sheets 8 | li Invite up to 5 people in a sheet at the same time 9 | 10 | .auth.signup-form 11 | col-md-6 12 | .basic-auth.login-block 13 | form(action="/signup", method="post") 14 | fieldset 15 | label User name (6-40 letters and numbers) 16 | br 17 | input(type="text", pattern="^[A-Za-z0-9]{6-40}$", name="username", placeholder="JohnSmith") 18 | fieldset 19 | label Your email address 20 | br 21 | input(type="email", name="email", placeholder="email@email.com") 22 | fieldset 23 | label Create a password (Minimum 6 characters) 24 | br 25 | input(type="password", name="password", pattern="^.{6,}$", placeholder="Password") 26 | br 27 | .text-center 28 | button(type="submit" value="submit") 29 | | Create my account 30 | col-md-6 31 | .facebook-login.login-block 32 | //h3 33 | // | Login with Facebook 34 | //p Not yet 35 | -------------------------------------------------------------------------------- /stats.js: -------------------------------------------------------------------------------- 1 | var redis = require("redis"); 2 | 3 | var client = redis.createClient() 4 | 5 | module.exports = {}; 6 | 7 | module.exports.new_sheet = function(){ 8 | client.incr("sheet_count", function(err, msg){ 9 | // Log a each 5 new sheets 10 | if(msg % 5 == 0){ 11 | console.log("total sheets: " + msg); 12 | } 13 | }); 14 | } 15 | 16 | module.exports.log_visit = function(what){ 17 | console.log("visit: " + what); 18 | } 19 | 20 | module.exports.new_sheet_visit = function(id){ 21 | client.incr("visit_sheet:"+id, function(err, msg){ 22 | if(err != null){ 23 | console.log(err); 24 | } 25 | }); 26 | } 27 | 28 | module.exports.get_sheet_visits = function(id, callback){ 29 | var callback = callback || function(){}; 30 | client.get("visit_sheet:"+id, function(err, msg){ 31 | if(err != null){ 32 | console.log(err); 33 | } 34 | callback(msg); 35 | }); 36 | } 37 | 38 | /* Marketing */ 39 | 40 | module.exports.newaccounts_newsletter_signup = function(email){ 41 | client.lpush("newsaccounts_newsletter",email, function(err, msg){ 42 | if(err != null){ 43 | console.log(err); 44 | } 45 | }); 46 | } 47 | -------------------------------------------------------------------------------- /views/templates.pug: -------------------------------------------------------------------------------- 1 | template(name='livecalc-die') 2 | h3 Create a new sheet 3 | a(href='/new') 4 | button Create a new sheet 5 | h3 Go to homepage 6 | a(href='/') 7 | button Homepage 8 | template(name='livechat') 9 | .sidebar-header.sidebar-resize-header.text-center 10 | h3 11 | | Chat 12 | .message-log 13 | .message-input 14 | textarea(placeholder='Join the conversation') 15 | button 16 | | Send 17 | template(name='livechat-received-message') 18 | .livechat-received-message 19 | .content 20 | .sender 21 | template(name='livechat-sent-message') 22 | .livechat-sent-message 23 | .content 24 | .sender 25 | template(name='small-doc') 26 | .doc 27 | include ./doc.pug 28 | template(name='single-field', data-params='class,default,info') 29 | .single-field(class='{{class}}') 30 | p {{info}} 31 | input(value='{{default}}') 32 | button Confirm 33 | template(name='livecalc-wait-click') 34 | .livecalc-wait-click 35 | button.livecalc-wait-click-button View 36 | p This might take some time to compute. 37 | template(name='modal-overlay') 38 | .modal-overlay 39 | template(name='modal-inform') 40 | .modal 41 | .content 42 | p 43 | .buttons 44 | include ./livecalc-templates.pug 45 | -------------------------------------------------------------------------------- /sass/pricing.scss: -------------------------------------------------------------------------------- 1 | /* pricing page */ 2 | 3 | .pricing-table{ 4 | margin:auto; 5 | font-size: 18px; 6 | background:#fff; 7 | padding:30px; 8 | } 9 | 10 | .pricing-table td{ 11 | padding:10px; 12 | background:#efefef; 13 | } 14 | 15 | .pricing-table .col-feature{ 16 | text-align: left; 17 | background: lighten($color_1,80%); 18 | } 19 | 20 | .pricing-table th{ 21 | padding: 20px 30px; 22 | } 23 | 24 | .pricing-table .yes{ 25 | background: lighten($color_3,20%); 26 | } 27 | 28 | .pricing p{ 29 | font-size:120%; 30 | font-weight:300; 31 | } 32 | 33 | .pricing .coming-soon{ 34 | font-weight:300; 35 | font-size:13px; 36 | } 37 | 38 | td.receive-alert{ 39 | padding:30px; 40 | } 41 | 42 | @media all and (max-width:768px) { 43 | .pricing-table{ 44 | font-size:10px; 45 | padding:5px; 46 | } 47 | .pricing-table tr{ 48 | margin:0; 49 | } 50 | .pricing .coming-soon{ 51 | font-size:8px; 52 | } 53 | .pricing-table td{ 54 | padding:2px; 55 | } 56 | .pricing-table th{ 57 | padding: 0px 3px; 58 | } 59 | .pricing-table input{ 60 | width:100px; 61 | } 62 | .pricing-table button{ 63 | font-size:10px; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | download: 2 | # math.js 3 | wget -N http://cdnjs.cloudflare.com/ajax/libs/mathjs/3.2.1/math.min.js -P public/lib/ 4 | # socket.io 5 | wget -N https://raw.githubusercontent.com/socketio/socket.io-client/master/socket.io.js -P public/lib/ 6 | # function-plot.js 7 | wget -N https://raw.githubusercontent.com/maurizzzio/function-plot/master/dist/function-plot.js -P public/lib/ 8 | # d3.js 9 | wget -N https://github.com/d3/d3/releases/download/v3.5.17/d3.zip -P public/lib/ 10 | unzip public/lib/d3.zip -d public/lib/d3 11 | mv -f public/lib/d3/d3.min.js public/lib/ 12 | rm -f public/lib/d3/* 13 | rmdir public/lib/d3 14 | rm public/lib/d3.zip 15 | # diff.js 16 | # https://github.com/kpdecker/jsdiff 17 | wget -N https://cdnjs.cloudflare.com/ajax/libs/jsdiff/2.2.3/diff.min.js -P public/lib 18 | 19 | fira: 20 | wget -N https://github.com/mozilla/Fira/archive/4.202.tar.gz -P public/fonts/fira-download 21 | # Remove old folder 22 | rm -rf public/fonts/fira-extract/ 23 | rm -rf public/fonts/fira/ 24 | mkdir -p public/fonts/fira-extract/ 25 | tar -zxvf public/fonts/fira-download/*.tar.gz -C public/fonts/fira-extract/ --exclude "source/*" 26 | mv public/fonts/fira-extract/* public/fonts/fira 27 | rm -rf public/fonts/fira-download public/fonts/fira-extract 28 | rm -rf public/fonts/fira/technical\ reports/ 29 | -------------------------------------------------------------------------------- /marketing/marketing.js: -------------------------------------------------------------------------------- 1 | /* handles pricing related pages */ 2 | 3 | module.exports = function(app, stats){ 4 | app.get('/pricing', function (req, res) { 5 | stats.log_visit("pricing-page"); 6 | res.render('base',{page: "pricing"}); 7 | }); 8 | 9 | app.post('/marketing/newsletter_signup', function (req, res) { 10 | var email = req.body.email; 11 | 12 | var validator = require("email-validator"); 13 | 14 | if(validator.validate(email)){ 15 | stats.newaccounts_newsletter_signup(email); 16 | console.log("newsletter signup: ", email); 17 | res.render('base',{ 18 | page: "pricing", 19 | positive_message:true, 20 | message: 21 | "Thank you for signing up to our newsletter! "+ 22 | "You will be informed when new "+ 23 | "user accounts are made available." 24 | }); 25 | } else { 26 | res.render('base',{ 27 | page: "pricing", 28 | negative_message:true, 29 | message: 30 | "We could not sign you up, "+ 31 | "there seems to be a problem with your email address." 32 | }); 33 | } 34 | }); 35 | }; 36 | -------------------------------------------------------------------------------- /views/base.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html 3 | head 4 | meta(charset='utf-8') 5 | meta 6 | // I'm so meta 7 | title livecalc.xyz Collaborative Calculator 8 | link(rel='stylesheet', href='/fonts/fira/fira.css') 9 | link(rel='stylesheet', href='/css/style.css') 10 | meta(name='viewport', content='width=device-width, initial-scale=1') 11 | meta(name='description', content='Multi-user calculator for all your projects that include math, plots & problems to solve.') 12 | meta(property='og:image', content='http://livecalc.xyz/images/logo-square.png') 13 | meta(property='og:title', content='livecalc.xyz open source collaborative calculator') 14 | meta(property='og:description', content='Multi-user calculator for all your projects that include math, plots & problems to solve.') 15 | body(class="page-"+(page || ""), class=in_sheet? "in-sheet": "") 16 | include ./header.pug 17 | if in_sheet 18 | col-md-9 19 | include ./page-content.pug 20 | col-md-3.sticky-sidebar 21 | include ./sidebar.pug 22 | else 23 | include ./page-content.pug 24 | include ./templates.pug 25 | 26 | script(src='/lib/math.min.js') 27 | script(src='/lib/d3.min.js', charset='utf-8') 28 | script(src='/lib/function-plot.js', charset='utf-8') 29 | script(src='/lib/socket.io.js') 30 | script(src='/lib/diff.min.js') 31 | script(src='/main.js') 32 | -------------------------------------------------------------------------------- /sheet_counter.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | 3 | module.exports.Counter = Counter; 4 | 5 | function Counter(){ 6 | var exports = {}; 7 | 8 | var data = {}; 9 | 10 | exports.data = data; 11 | 12 | data.counters = {}; 13 | 14 | var counters = data.counters; 15 | var types = []; 16 | 17 | exports.plus = function(type){ 18 | if(counters[type] == undefined){ 19 | counters[type] = 0; 20 | types.push(type); 21 | } 22 | data.counters[type]++; 23 | } 24 | 25 | exports.minus = function(type){ 26 | if(counters[type] == undefined){ 27 | console.log( 28 | "Error: Trying to decrement undefined counter"); 29 | } 30 | 31 | if(counters[type] == 0){ 32 | console.log( 33 | "Error: Trying to decrement counter = 0, type: "+type); 34 | } 35 | 36 | data.counters[type]--; 37 | } 38 | 39 | /* 40 | Returns total if type is null/undefined 41 | */ 42 | exports.get = function(type){ 43 | if(type == undefined || type == null){ 44 | var tot = 0; 45 | for(t in types){ 46 | tot += data.counters[t]; 47 | } 48 | return tot; 49 | } 50 | 51 | return data.counters[type] || 0; 52 | } 53 | 54 | return exports; 55 | } 56 | -------------------------------------------------------------------------------- /Users.md: -------------------------------------------------------------------------------- 1 | # Users, session, etc. 2 | 3 | ## login 4 | 5 | When a user 6 | 7 | ## session_id 8 | 9 | This is stored in a cookie and is currently valid for one login. 10 | 11 | We have to validate if the session really exists in redis at each request. 12 | 13 | ## public_id (sometimes user_id in frontend code) 14 | 15 | public_id in the server side is the same thing as user_id in the frontend side. 16 | 17 | It allows, for example, to identify who as sent what message, who is editing what cell, etc. 18 | 19 | Previously, public_id and user_id used to be updatable by frontend, but this should never be the case anymore. 20 | 21 | ## Ideas to prevent session hijacking 22 | 23 | Change the session_id at each page load. 24 | 25 | See: [this wikipedia article](https://en.wikipedia.org/wiki/Session_hijacking#Prevention) 26 | 27 | ## What happens when a user logs in 28 | 29 | Example of a successful login and sheet interaction. 30 | 31 | * User logins, password is matched to mongodb password 32 | * Session is created, cookie with id is sent 33 | * Data is fetched from mongo and put in redis 34 | * User goes to a sheet 35 | * Socket.io connection and cookie reception 36 | * Verify if session exists 37 | * Add user to memory 38 | * Send nickname and public_id to client 39 | * Past messages are loaded in chat, messages where 40 | `public id == current user id` 41 | are highlighted as own-message. 42 | * etc. -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | -*- mode: gitignore; -*- 2 | *~ 3 | \#*\# 4 | /.emacs.desktop 5 | /.emacs.desktop.lock 6 | *.elc 7 | auto-save-list 8 | tramp 9 | .\#* 10 | 11 | # Org-mode 12 | .org-id-locations 13 | *_archive 14 | 15 | # flymake-mode 16 | *_flymake.* 17 | 18 | # eshell files 19 | /eshell/history 20 | /eshell/lastdir 21 | 22 | # elpa packages 23 | /elpa/ 24 | 25 | # reftex files 26 | *.rel 27 | 28 | # AUCTeX auto folder 29 | /auto/ 30 | 31 | # cask packages 32 | .cask/ 33 | dist/ 34 | 35 | # Flycheck 36 | flycheck_*.el 37 | 38 | # server auth directory 39 | /server/ 40 | 41 | # projectiles files 42 | .projectile 43 | 44 | Logs 45 | logs 46 | *.log 47 | npm-debug.log* 48 | 49 | # Runtime data 50 | pids 51 | *.pid 52 | *.seed 53 | 54 | # Directory for instrumented libs generated by jscoverage/JSCover 55 | lib-cov 56 | 57 | # Coverage directory used by tools like istanbul 58 | coverage 59 | 60 | # nyc test coverage 61 | .nyc_output 62 | 63 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 64 | .grunt 65 | 66 | # node-waf configuration 67 | .lock-wscript 68 | 69 | # Compiled binary addons (http://nodejs.org/api/addons.html) 70 | build/Release 71 | 72 | # Dependency directories 73 | node_modules 74 | jspm_packages 75 | 76 | # Optional npm cache directory 77 | .npm 78 | 79 | # Optional REPL history 80 | .node_repl_history 81 | 82 | # Fonts folder 83 | public/fonts 84 | 85 | # Generated CSS 86 | public/css/** -------------------------------------------------------------------------------- /views/palette-greek-letters.pug: -------------------------------------------------------------------------------- 1 | .palette-greek-letters 2 | h4 Greek Letters 3 | button(title='alpha') Α 4 | button(title='alpha') α 5 | button(title='beta') Β 6 | button(title='beta') β 7 | button(title='gamma') Γ 8 | button(title='gamma') γ 9 | button(title='delta') Δ 10 | button(title='delta') δ 11 | button(title='epsilon') Ε 12 | button(title='epsilon') ε 13 | button(title='zeta') Ζ 14 | button(title='zeta') ζ 15 | button(title='eta') Η 16 | button(title='eta') η 17 | button(title='theta') Θ 18 | button(title='theta') θ 19 | button(title='iota') Ι 20 | button(title='iota') ι 21 | button(title='kappa') Κ 22 | button(title='kappa') κ 23 | button(title='lambda') Λ 24 | button(title='lambda') λ 25 | button(title='mu') Μ 26 | button(title='mu') μ 27 | button(title='nu') Ν 28 | button(title='nu') ν 29 | button(title='xi') Ξ 30 | button(title='xi') ξ 31 | button(title='omicron') Ο 32 | button(title='omicron') ο 33 | button(title='pi') Π 34 | button(title='pi') π 35 | button(title='rho') Ρ 36 | button(title='rho') ρ 37 | button(title='sigma') Σ 38 | button(title='sigma') σ 39 | button(title='sigma') ς 40 | button(title='tau') Τ 41 | button(title='tau') τ 42 | button(title='upsilon') Υ 43 | button(title='upsilon') υ 44 | button(title='phi') Φ 45 | button(title='phi') φ 46 | button(title='chi') Χ 47 | button(title='chi') χ 48 | button(title='psi') Ψ 49 | button(title='psi') ψ 50 | button(title='omega') Ω 51 | button(title='omega') ω 52 | -------------------------------------------------------------------------------- /sass/chat.scss: -------------------------------------------------------------------------------- 1 | /* Chat */ 2 | 3 | livechat{ 4 | background:#eee; 5 | position:relative; 6 | display:block; 7 | } 8 | 9 | .livechat-sent-message, 10 | .livechat-received-message{ 11 | width:90%; 12 | word-wrap: break-word; 13 | margin-bottom:5px; 14 | margin-top:5px; 15 | padding:10px; 16 | border-radius:10px; 17 | } 18 | 19 | .livechat-received-message{ 20 | color:$color_2; 21 | background:#fff; 22 | border:1px solid darken(#eee,4%); 23 | border-top-left-radius:0; 24 | margin-left:0; 25 | margin-right:auto; 26 | } 27 | 28 | .livechat-sent-message{ 29 | color:$color_2; 30 | background:$color_5; 31 | border:1px solid darken($color_5,5%); 32 | border-bottom-right-radius:0; 33 | margin-left:auto; 34 | margin-right:0; 35 | } 36 | 37 | livechat .header{ 38 | padding:5px; 39 | background: $color_3; 40 | } 41 | 42 | livechat .header h3{ 43 | color:#fff; 44 | font-size:15px; 45 | } 46 | 47 | livechat button{ 48 | position:absolute; 49 | bottom:0px; 50 | right:10px; 51 | } 52 | 53 | livechat textarea{ 54 | position:absolute; 55 | bottom:0px; 56 | left:10px; 57 | height:50px; 58 | padding:5px; 59 | border:none; 60 | font-size:13px; 61 | background:#fff; 62 | resize:none; 63 | } 64 | 65 | livechat .message-log{ 66 | overflow-y: scroll; 67 | overflow-x:hidden; 68 | max-height:100%; 69 | padding-left:5px; 70 | padding-right:15px; 71 | } 72 | 73 | livechat .sender{ 74 | font-size:10px; 75 | text-align:right; 76 | } 77 | -------------------------------------------------------------------------------- /views/landing.pug: -------------------------------------------------------------------------------- 1 | .landing.only-landing 2 | h1.text-center Solve problems together 3 | p.text-center 4 | | livecalc.xyz is a collaborative calculator 5 | br 6 | br 7 | | To use livecalc.xyz, create a 8 | a(href='/new', target='_blank') new sheet 9 | br 10 | | Up to 3 users can edit a sheet at the same time, 11 | br 12 | | so share the sheet URL to your friends/classmates/colleagues! 13 | br 14 | br 15 | | Have fun! 16 | br 17 | br 18 | .features 19 | .block-center 20 | h3 Features 21 | p 22 | ul 23 | li Play with numbers 24 | ul 25 | li a+b, a-b (addition, substraction) 26 | li a*b, a/b (multiplication, division) 27 | li a^b (exponent) 28 | li sqrt(a) (square root) 29 | li sin(x), cos(x), tan(x) (trigonometric functions in rad) 30 | li sin(45 deg) (same, in degree) 31 | li n! (factorial) 32 | li For the complete syntax documentation, see 33 | a( 34 | href="http://mathjs.org/docs/expressions/syntax.html", 35 | target="_blank" 36 | ) here 37 | li Plots Functions 38 | li Store values in variables 39 | li Convert units 40 | li Generate fractals with complex numbers 41 | li Chat with your teammates 42 | li Completely open source 43 | p.text-center 44 | | Contribute code & issues on 45 | a(href='https://github.com/antoineMoPa/livecalc', target='_blank') github 46 | -------------------------------------------------------------------------------- /sheet_db.js: -------------------------------------------------------------------------------- 1 | var redis = require("redis"); 2 | 3 | var client = redis.createClient(); 4 | 5 | module.exports = {}; 6 | 7 | function gen_id(id){ 8 | var id = id.replace(/[^A-Za-z0-9_]/g,""); 9 | return "sheet:"+id; 10 | } 11 | 12 | test(); 13 | 14 | function test(){ 15 | if(gen_id("*a*") != "sheet:a"){ 16 | console.log("Test failed "+gen_id("*a*")); 17 | } 18 | } 19 | 20 | module.exports.store_sheet = function(id, data){ 21 | var data = JSON.stringify(data); 22 | 23 | var id = gen_id(id); 24 | 25 | if(id.length == 0){ 26 | return; 27 | } 28 | 29 | client.set(id, data, function(err, reply){ 30 | if(err != null){ 31 | console.log("err: " + err); 32 | } 33 | }); 34 | }; 35 | 36 | /* 37 | 38 | callback(data) 39 | 40 | */ 41 | module.exports.get_sheet = function(id, callback){ 42 | var id = gen_id(id); 43 | 44 | if(id.length == 0){ 45 | return; 46 | } 47 | 48 | client.get(id, function(err, reply){ 49 | if(err != null){ 50 | console.log("err: " + err); 51 | } 52 | callback(JSON.parse(reply)); 53 | }); 54 | }; 55 | 56 | /* 57 | 58 | callback(bool: exists) 59 | 60 | */ 61 | module.exports.exists = function(id, callback){ 62 | var id = gen_id(id); 63 | 64 | if(id.length == 0){ 65 | callback(false); 66 | return; 67 | } 68 | 69 | client.exists(id, function(err, exists){ 70 | if(err != null){ 71 | console.log("err: " + err); 72 | } 73 | callback(exists); 74 | }); 75 | }; 76 | -------------------------------------------------------------------------------- /views/chars.txt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /views/page-content.pug: -------------------------------------------------------------------------------- 1 | .main-page 2 | if positive_message 3 | .server-message.positive 4 | p 5 | = message 6 | if negative_message 7 | .server-message.negative 8 | p 9 | = message 10 | 11 | if page != undefined 12 | .static 13 | if page == "landing" 14 | include ./landing.pug 15 | else if page == "pricing" 16 | .pricing 17 | include ./pricing.pug 18 | else if page == "signup" 19 | .signup 20 | include ./signup.pug 21 | else if page == "account" 22 | .account 23 | include ./account.pug 24 | else if page == "login" 25 | .login 26 | include ./login.pug 27 | else if page == "dashboard" 28 | .dashboard 29 | include ./dashboard.pug 30 | else if page == "sheet" 31 | col-md-6 32 | p To get started, scroll down and see the quick reference. 33 | col-md-6 34 | p.text-right.livecalc-sharing 35 | | Share this sheet: 36 | a(target="_blank", href="https://www.facebook.com/sharer/sharer.php?u=" + share_url) 37 | | facebook 38 | a(target="_blank", href=("https://twitter.com/intent/tweet?text=View%20this%20livecalc.xyz%20sheet:%20" + share_url)) 39 | | twitter 40 | a(target="_blank",href=share_url).url-popup-modal 41 | | URL 42 | livecalc 43 | else 44 | p 45 | | Content not found 46 | br 47 | p.text-center 48 | | Licence: 49 | a(href='https://www.gnu.org/licenses/gpl-3.0.en.html', 50 | target='_blank') 51 | | GPLv3 52 | br 53 | -------------------------------------------------------------------------------- /views/account.pug: -------------------------------------------------------------------------------- 1 | .account-content 2 | h3 Your Account 3 | col-md-6 4 | h4 Change your username 5 | form(action="/account-username", method="post") 6 | fieldset 7 | label User name (6-40 letters and numbers) 8 | br 9 | input(type="text", pattern="^[A-Za-z0-9]{6,40}$", 10 | name="username", placeholder="JohnSmith", 11 | value=user.get_username() 12 | ) 13 | .text-left 14 | button(type="submit" value="submit") 15 | | Change username 16 | h4 Change email address 17 | form(action="/account-email", method="post") 18 | fieldset 19 | label New email address 20 | br 21 | input( 22 | type="email", name="email", 23 | placeholder="email@email.com", 24 | value=user.get_email() 25 | ) 26 | .text-left 27 | button(type="submit" value="submit") 28 | | Change email address 29 | col-md-6 30 | h4 Password Change 31 | form(action="/account-password", method="post") 32 | fieldset 33 | label Current password 34 | br 35 | input(type="password", name="current_password", pattern="^.{6,}$", placeholder="Current password") 36 | br 37 | br 38 | label New password 39 | br 40 | input(type="password", name="new_password", pattern="^.{6,}$", placeholder="New password") 41 | br 42 | br 43 | label Retype new password 44 | br 45 | input(type="password", name="new_password_repeat", pattern="^.{6,}$", placeholder="Retype new password") 46 | .text-left 47 | button(type="submit" value="submit") 48 | | Change password 49 | 50 | br 51 | hr 52 | p.text-center 53 | a(href="/dashboard") 54 | button 55 | | Go back to my dashboard 56 | -------------------------------------------------------------------------------- /sheet_model.js: -------------------------------------------------------------------------------- 1 | var deepcopy = require("deepcopy"); 2 | 3 | var default_sheet = { 4 | "params":{ 5 | "title":"Empty", 6 | "locked":false, 7 | }, 8 | "cells":[ 9 | "" 10 | ] 11 | }; 12 | 13 | module.exports = {}; 14 | 15 | module.exports.create = function(){ 16 | var sheet = deepcopy(default_sheet); 17 | 18 | var exports = {}; 19 | 20 | exports.edit = function(data){ 21 | if(sheet.params.locked){ 22 | return; 23 | } 24 | 25 | var number = parseInt(data.number); 26 | var content = data.content; 27 | var method = data.method; 28 | 29 | if(method == "insert"){ 30 | sheet.cells.splice(number, 0, content); 31 | } else if(number >= 0){ 32 | sheet.cells[number] = content; 33 | } 34 | } 35 | 36 | exports.remove = function(data){ 37 | if(sheet.params.locked){ 38 | return; 39 | } 40 | 41 | var number = data.number; 42 | 43 | var len = sheet.cells.length; 44 | if((number > 0 || len > 1) && number < len){ 45 | sheet.cells.splice(number, 1); 46 | } 47 | } 48 | 49 | exports.lock = function(){ 50 | sheet.params.locked = true; 51 | } 52 | 53 | exports.is_locked = function(){ 54 | if(sheet.params.locked == undefined){ 55 | sheet.params.locked = false; 56 | } 57 | return sheet.params.locked; 58 | } 59 | 60 | exports.set_sheet = function(data){ 61 | if(sheet.params.locked){ 62 | return sheet; 63 | } 64 | 65 | sheet = data; 66 | sheet.params = sheet.params || {}; 67 | sheet.params.locked = sheet.params.locked || false; 68 | 69 | return sheet; 70 | } 71 | 72 | exports.get_sheet = function(){ 73 | return sheet; 74 | } 75 | 76 | exports.get_length = function(){ 77 | return sheet.cells.length + 1; 78 | } 79 | 80 | return exports; 81 | } 82 | -------------------------------------------------------------------------------- /sass/header.scss: -------------------------------------------------------------------------------- 1 | header .links a{ 2 | margin:0px 10px; 3 | } 4 | 5 | header h1{ 6 | line-height:19px; 7 | font-family: 'Fira Sans'; 8 | font-weight:300; 9 | } 10 | 11 | header p{ 12 | margin:15px 20px; 13 | } 14 | 15 | @media (max-width:768px){ 16 | header h1{ 17 | text-align:center!important; 18 | display:block!important; 19 | } 20 | } 21 | 22 | header h1 a{ 23 | color:#fff; 24 | } 25 | 26 | @media (max-width:500px){ 27 | h1{ 28 | margin:10px; 29 | margin-top:20px; 30 | display:block!important; 31 | text-align:center; 32 | } 33 | } 34 | 35 | header{ 36 | background:$color_2; 37 | color:#fff; 38 | width:100%; 39 | display:block; 40 | padding-top:4px; 41 | padding-bottom:4px; 42 | position:relative; 43 | z-index:100; 44 | } 45 | 46 | @media (max-width:500px){ 47 | header .col{ 48 | display:block; 49 | width:100%; 50 | } 51 | } 52 | 53 | header a{ 54 | color:$color_5; 55 | text-decoration:none; 56 | } 57 | 58 | header button{ 59 | margin:0; 60 | padding:10px 12px; 61 | margin-top:6px; 62 | height:auto; 63 | border-radius:3px; 64 | border:2px solid $body_bg; 65 | } 66 | 67 | .new-sheet-button{ 68 | color:#fff; 69 | display:inline-block; 70 | margin-right:20px; 71 | } 72 | 73 | @media (max-width:1250px){ 74 | .new-sheet-button button{ 75 | font-size:11px; 76 | margin-top:8px 77 | } 78 | } 79 | 80 | .new-sheet-button button{ 81 | word-wrap: nowrap; 82 | } 83 | 84 | @media (max-width:768px){ 85 | .new-sheet-button{ 86 | margin-right:0; 87 | } 88 | header{ 89 | padding-bottom:20px; 90 | } 91 | } 92 | 93 | nav{ 94 | text-align:center; 95 | } 96 | 97 | nav ul{ 98 | padding:0; 99 | margin:0; 100 | background:$color_5; 101 | } 102 | 103 | nav li{ 104 | display: inline-block; 105 | padding:5px 20px; 106 | &:hover{ 107 | background:darken($color_5, 10%); 108 | } 109 | } 110 | 111 | nav a{ 112 | text-decoration: none; 113 | } -------------------------------------------------------------------------------- /sass/base.scss: -------------------------------------------------------------------------------- 1 | /* general stuff that should be everywhere */ 2 | .text-right{ 3 | text-align:right; 4 | } 5 | 6 | .text-left{ 7 | text-align:left; 8 | } 9 | 10 | .text-center{ 11 | text-align:center; 12 | } 13 | 14 | .strong{ 15 | font-weight:600; 16 | } 17 | 18 | .bigger{ 19 | font-size:150%; 20 | } 21 | 22 | .smaller{ 23 | font-size:80%!important; 24 | } 25 | 26 | @media (max-width:768px){ 27 | .mobile-center{ 28 | display:block!important; 29 | text-align:center; 30 | } 31 | } 32 | 33 | col-md-12, 34 | col-md-11, 35 | col-md-10, 36 | col-md-9, 37 | col-md-8, 38 | col-md-7, 39 | col-md-6, 40 | col-md-5, 41 | col-md-4, 42 | col-md-3, 43 | col-md-2, 44 | col-md-1{ 45 | display:inline-block; 46 | vertical-align:top; 47 | } 48 | 49 | col-sm-12, 50 | col-sm-11, 51 | col-sm-10, 52 | col-sm-9, 53 | col-sm-8, 54 | col-sm-7, 55 | col-sm-6, 56 | col-sm-5, 57 | col-sm-4, 58 | col-sm-3, 59 | col-sm-2, 60 | col-sm-1{ 61 | display:inline-block; 62 | vertical-align:top; 63 | } 64 | 65 | col-md-1{ 66 | width:8%; 67 | } 68 | 69 | col-md-2{ 70 | width:16%; 71 | } 72 | 73 | col-md-3{ 74 | width:25%; 75 | } 76 | 77 | col-md-4{ 78 | width:33%; 79 | margin: 0 -1px; 80 | } 81 | 82 | col-md-6{ 83 | width:49.5%; 84 | } 85 | 86 | col-md-8{ 87 | width:65%; 88 | } 89 | 90 | col-md-9{ 91 | width:74%; 92 | } 93 | 94 | col-md-10{ 95 | width:82%; 96 | } 97 | 98 | col-sm-6{ 99 | width:49%; 100 | } 101 | 102 | @media (max-width:768px){ 103 | col-md-1, 104 | col-md-2, 105 | col-md-3, 106 | col-md-4, 107 | col-md-5, 108 | col-md-6, 109 | col-md-7, 110 | col-md-8, 111 | col-md-9, 112 | col-md-10, 113 | col-md-11, 114 | col-md-12{ 115 | display:block; 116 | width:100%; 117 | } 118 | } 119 | 120 | .hidden{ 121 | display:none; 122 | } 123 | 124 | .flashing{ 125 | background: $color_3 !important; 126 | color: #fff; 127 | } 128 | 129 | .inline-block{ 130 | display:inline-block; 131 | vertical-align:top; 132 | } 133 | 134 | span.spacer{ 135 | width:30px; 136 | } -------------------------------------------------------------------------------- /views/livecalc-templates.pug: -------------------------------------------------------------------------------- 1 | template(name='livecalc') 2 | .livecalc 3 | .livecalc-header 4 | col-md-4.text-left.mobile-center 5 | .sheet-state 6 | col-md-4.text-center 7 | .user-count(title='(Including you)') 8 | col-md-4.text-right 9 | .user-info.mobile-center 10 | span.user-name 11 | | Anonymous 12 | if logged_in 13 | | - 14 | a(href="/dashboard", target="_blank") dashboard 15 | | - 16 | a(href="/logout") Log out 17 | else 18 | | - 19 | a(href="/login") Log in 20 | .livecalc-cells 21 | template-instance(data-name='sheet-panel') 22 | .panel 23 | h3 Settings 24 | template-instance(data-name='single-field', 25 | data-info='This name is used in the chat: ' 26 | data-class='nickname', 27 | data-default='anonymous') 28 | template-instance(data-name='small-doc') 29 | template(name='sheet-panel') 30 | .sheet-panel.panel 31 | h3 Sheet Panel 32 | col-md-3 33 | | Lock sheet from further editing 34 | br 35 | button(name='lock-sheet') 36 | | Lock Sheet 37 | col-md-3 38 | | Open a copy 39 | br 40 | button(name='new-copy') 41 | | New Copy 42 | br 43 | h3 Stats 44 | p 45 | span.visit-count 46 | 0 47 | | visits. 48 | template(name='plot-interact-button') 49 | .text-center 50 | button.plot-interact 51 | | Interact with this plot 52 | template(name='fullscreen') 53 | .fullscreen 54 | .fullscreen-header 55 | col-sm-6 56 | h3.fullscreen-title 57 | col-sm-6 58 | .text-right 59 | button.close-button 60 | | Close 61 | .content 62 | template(name='livecalc-cell') 63 | .cell-and-button-wrapper 64 | .add-cell-button 65 | p.text-center.inner 66 | | + 67 | .livecalc-cell 68 | .delete-cell-button.icon.delete-icon 69 | .text-part 70 | .math-part 71 | fieldset 72 | div 73 | input.inline-block.livecalc-input( 74 | type='text', 75 | placeholder='Type math') 76 | button.inline-block.livecalc-go-button 77 | | Go 78 | pre.livecalc-output. 79 | pre.livecalc-secondary-output 80 | p.users-info 81 | .plot 82 | -------------------------------------------------------------------------------- /views/doc.pug: -------------------------------------------------------------------------------- 1 | h2 livecalc.xyz Quick Reference 2 | col-md-6 3 | h3 Basics 4 | p 5 | | Type math: 6 | code 13*42*(3+32)/12 7 | br 8 | | Data is saved when you press GO/Enter 9 | br 10 | | Copy/Paste URL to share sheet 11 | br 12 | | Use backspace to delete an empty cell 13 | br 14 | | Use semicolon (;) to write what you 15 | | would write on many cells: 16 | br 17 | code a = 1; b=2; sin(a+b) 18 | br 19 | | Navigation: Tab/Shift+tab 20 | h3 Numbers 21 | p 22 | | You can use the electrical engineering 23 | | notation with numbers (n,u,m,k/K,meg/M,G) 24 | br 25 | | ex: 26 | code 1K 27 | | = 1000 28 | br 29 | h3 Functions 30 | p 31 | | You can define functions like that: 32 | code f(x) = x**2 33 | br 34 | | Then you can use f(x): 35 | code f(pi) 36 | h3 Constants 37 | p 38 | code pi 39 | | , 40 | code e 41 | | (Euler), 42 | code i 43 | | (sqrt(-1)), etc. 44 | a(href='http://mathjs.org/docs/reference/constants.html', 45 | target='_blank') mathjs constants 46 | h3 Plots 47 | p 48 | | You can plot an expression: 49 | br 50 | code plot("sin(x+0.5)") 51 | br 52 | code plot("e**x") 53 | br 54 | | Plot more than one function at once: 55 | br 56 | code plot("e**x","sin(x)") 57 | h3 Fractals 58 | p 59 | | For 10 iterations 60 | | and a size of 140 pixels: 61 | br 62 | | Julia: 63 | code zfractal("z**2+0.4-0.05i",10,140) 64 | br 65 | | Mandelbrot: 66 | code zfractal("z**2+c",10,140) 67 | br 68 | | Cellular automaton: 69 | code rule(45) 70 | col-md-6 71 | h3 Math 72 | p 73 | code sin(3.14) 74 | br 75 | code sin(360deg) 76 | br 77 | code a = 30 78 | br 79 | code b = 3 * a 80 | br 81 | code b 82 | | (to show the value of 'b') 83 | br 84 | h3 Conversions 85 | p 86 | code 3 meters to inches 87 | br 88 | code 3 in to cm 89 | br 90 | code 1 gal to L 91 | br 92 | code 1 L to cm^3 93 | br 94 | code 4 bytes in bits 95 | br 96 | br 97 | h3 Comments 98 | p 99 | | Comments exist to let you explain what you are doing. 100 | br 101 | | Create text cells with this syntax: 102 | code // Comments can be used to describe what's next 103 | br 104 | | Once created, click a comment to edit it. 105 | br 106 | | Use comments at the end the line to describe 107 | | what you are doing: 108 | code 1+1 // Here is a simple addition 109 | h3 Bonus 110 | p 111 | code a=1K; b=2K; LL(a,b) 112 | | : 113 | | equivalent value of resistors in parallel. 114 | -------------------------------------------------------------------------------- /user_cache.js: -------------------------------------------------------------------------------- 1 | var redis = require("redis"); 2 | 3 | var client = redis.createClient(); 4 | 5 | module.exports = {}; 6 | 7 | function session_db_id(id){ 8 | var id = id.replace(/[^A-Za-z0-9]/g,""); 9 | return "user_session:"+id; 10 | } 11 | 12 | module.exports.store_user = function(id, data){ 13 | var data = JSON.stringify(data); 14 | 15 | var id = session_db_id(id); 16 | 17 | if(id.length == 0){ 18 | return; 19 | } 20 | 21 | client.set(id, data, function(err, reply){ 22 | if(err != null){ 23 | console.log("err: " + err); 24 | } 25 | }); 26 | }; 27 | 28 | module.exports.logout = function(session_id){ 29 | var id = session_db_id(session_id); 30 | client.del(id, function(err, reply){ 31 | if(err != null){ 32 | console.log("err: " + err); 33 | } 34 | }); 35 | }; 36 | 37 | /* 38 | 39 | Gets user from temp db 40 | callback(data) 41 | 42 | */ 43 | module.exports.get_user = function(id, callback){ 44 | var id = session_db_id(id); 45 | 46 | if(id.length == 0){ 47 | return; 48 | } 49 | 50 | client.get(id, function(err, reply){ 51 | if(err != null){ 52 | console.log("err: " + err); 53 | } 54 | callback(JSON.parse(reply)); 55 | }); 56 | }; 57 | 58 | /* 59 | 60 | Does a temp user exist in redis? 61 | 62 | callback(bool: exists) 63 | 64 | */ 65 | module.exports.exists = function(id, callback){ 66 | if(id == undefined || id == ""){ 67 | return false; 68 | } 69 | 70 | var id = session_db_id(id); 71 | 72 | if(id.length == 0){ 73 | callback(false); 74 | return; 75 | } 76 | 77 | client.exists(id, function(err, exists){ 78 | if(err != null){ 79 | console.log("err: " + err); 80 | } 81 | callback(exists); 82 | }); 83 | }; 84 | 85 | function visited_sheets_id(user_id){ 86 | var visited_id = "user_sheet_visits:"+user_id+":"; 87 | 88 | return visited_id; 89 | } 90 | 91 | /* 92 | Call whenever a sheet is visited by a user 93 | */ 94 | module.exports.visit_sheet = function(user_id, sheet_ids){ 95 | var visited_id = visited_sheets_id(user_id); 96 | 97 | client.lpush(visited_id, sheet_ids, function(err, reply){ 98 | if(err != null){ 99 | console.log("err: " + err); 100 | } 101 | }); 102 | }; 103 | 104 | /* 105 | Call whenever a sheet is visited by a user 106 | callback(sheets) 107 | */ 108 | module.exports.recently_visited_sheets = function(user_id, callback){ 109 | var visited_id = visited_sheets_id(user_id); 110 | 111 | client.lrange(visited_id, -10, -1, function(err, reply){ 112 | if(err != null){ 113 | console.log("err: " + err); 114 | } else { 115 | callback(reply); 116 | } 117 | }); 118 | }; 119 | -------------------------------------------------------------------------------- /public/icons/livecalc.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 15 | 17 | 18 | 20 | image/svg+xml 21 | 23 | 24 | 25 | 26 | 27 | 30 | 33 | 39 | 48 | 57 | 58 | 67 | livecalc-source.svg: inscape svglivecalc.svg: plain svg 79 | 80 | 81 | -------------------------------------------------------------------------------- /public/logo/livecalc.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 22 | 24 | 43 | 45 | 46 | 48 | image/svg+xml 49 | 51 | 52 | 53 | 54 | 55 | 60 | 63 | 68 | LC 80 | 85 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /sass/livecalc.scss: -------------------------------------------------------------------------------- 1 | .livecalc-cell{ 2 | margin-top:5px; 3 | margin-bottom:5px; 4 | position:relative; 5 | } 6 | 7 | .cell-and-button-wrapper{ 8 | .delete-cell-button{ 9 | position:absolute; 10 | cursor:pointer; 11 | top:-10px; 12 | right:-10px; 13 | opacity:0.1; 14 | transition:all 0.3s; 15 | &:hover{ 16 | opacity:1; 17 | transform:rotate(90deg); /* oh yeah! */ 18 | } 19 | } 20 | 21 | /* last cell can't be deleted anyway */ 22 | &:last-child .delete-cell-button{ 23 | display:none; 24 | } 25 | } 26 | 27 | .livecalc-cell .text-part{ 28 | background:#fff; 29 | color:#000; 30 | padding:10px; 31 | padding-left:20px; 32 | font-family:monospace; 33 | font-size:13px; 34 | cursor:pointer; 35 | } 36 | 37 | .livecalc-cell .math-part, 38 | .livecalc .livecalc-header{ 39 | background:$color_4; 40 | padding-top:5px; 41 | padding-bottom:5px; 42 | } 43 | 44 | .livecalc fieldset{ 45 | border:none; 46 | margin:0; 47 | padding:0; 48 | padding-top:5px; 49 | } 50 | 51 | .livecalc .livecalc-input{ 52 | width: 100%; 53 | width: calc(100% - 150px); 54 | margin-left:10px; 55 | font-size:20px; 56 | margin-bottom:5px; 57 | color:#444; 58 | border:none; 59 | background:#fff; 60 | box-shadow:none; 61 | font-family:monospace; 62 | } 63 | 64 | .livecalc-secondary-output, 65 | .livecalc-output{ 66 | margin-top:5px; 67 | margin-left:10px; 68 | margin-bottom:5px; 69 | margin-right:10px; 70 | min-height:20px; 71 | background:#fff; 72 | font-size:20px; 73 | padding:5px; 74 | padding-left:20px; 75 | border-radius: 2px; 76 | } 77 | 78 | .livecalc-secondary-output{ 79 | min-height:0; 80 | font-size:10px; 81 | margin-top:10px; 82 | } 83 | 84 | .livecalc-go-button{ 85 | height:32px; 86 | margin:0; 87 | margin-left:10px; 88 | width:100px; 89 | border-radius:0; 90 | border:none; 91 | padding:5px; 92 | background:$color_2; 93 | color:#eee; 94 | border-bottom:2px solid $body_bg; 95 | } 96 | 97 | .livecalc .users-info{ 98 | text-align:right; 99 | font-size:11px; 100 | margin:5px; 101 | } 102 | 103 | .livecalc .plot{ 104 | margin:10px 10px; 105 | } 106 | 107 | .livecalc .plot .livecalc-wait-click, 108 | .livecalc .plot svg, 109 | .livecalc .plot canvas{ 110 | background:#fff; 111 | margin:auto; 112 | display:block; 113 | } 114 | 115 | .livecalc-wait-click{ 116 | max-width:400px; 117 | text-align:center; 118 | padding:10px; 119 | padding-bottom:2px; 120 | border-radius:2px; 121 | } 122 | 123 | .livecalc-wait-click-button{ 124 | margin-bottom:0; 125 | box-shadow: 0 0 5px rgba(0,0,0,0.1); 126 | } 127 | 128 | .livecalc-header{ 129 | text-align:left; 130 | font-size:14px; 131 | color:darken($color_3, 20%); 132 | } 133 | 134 | .livecalc-header .sheet-state{ 135 | margin-left:10px; 136 | } 137 | 138 | .livecalc-header .user-info{ 139 | padding-right:10px; 140 | } 141 | 142 | livecalc{ 143 | overflow-x:hidden; 144 | } 145 | 146 | .panel{ 147 | background:$color_4; 148 | padding:10px; 149 | margin-top:10px; 150 | margin-bottom:10px; 151 | } 152 | 153 | .livecalc-sharing a{ 154 | margin-right:10px; 155 | } 156 | 157 | .add-cell-button{ 158 | margin-top:5px; 159 | .inner{ 160 | background:darken($color_4,10%); 161 | border-radius:3px; 162 | width:20px; 163 | margin:auto; 164 | color:#fff; 165 | line-height:5px; 166 | cursor:pointer; 167 | transition: line-height 0.1s; 168 | &:hover{ 169 | background:darken($color_4,20%); 170 | line-height:15px; 171 | } 172 | } 173 | } -------------------------------------------------------------------------------- /public/icons/livecalc-source.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 40 | 44 | 45 | 47 | 48 | 50 | image/svg+xml 51 | 53 | 54 | 55 | 56 | 57 | 62 | 65 | 71 | 80 | 89 | 90 | 101 | livecalc-source.svg: inscape svglivecalc.svg: plain svg 116 | 117 | 118 | -------------------------------------------------------------------------------- /user_db.js: -------------------------------------------------------------------------------- 1 | /* user_db.js */ 2 | /* 3 | For the moment, this deals with both redis and mongo, 4 | this could be changed. 5 | */ 6 | 7 | var password_hash = require("password-hash"); 8 | 9 | module.exports = {}; 10 | 11 | var mongoose = require('mongoose'); 12 | 13 | mongoose.connect('mongodb://localhost/livecalc'); 14 | 15 | var db = mongoose.connection; 16 | 17 | db.on('error', console.error.bind(console, 'connection error:')); 18 | 19 | db.once('open', function() { 20 | console.log("mongo connection"); 21 | }); 22 | 23 | var UserSchema = new mongoose.Schema({ 24 | name: String, 25 | public_id: String, /* to know who sent what message, for example. 26 | Since it is visible to everybody, it must not 27 | allow to do dangerous things. 28 | */ 29 | username: String, 30 | password: String, 31 | email: String, 32 | nickname: String, 33 | sheets: Array, 34 | recent_sheets: Array 35 | }); 36 | 37 | UserSchema.methods.verify_password = function(given_password){ 38 | if(password_hash.verify(given_password, this.password)){ 39 | return true; 40 | } 41 | 42 | return false; 43 | }; 44 | 45 | UserSchema.methods.update_password = function(new_password){ 46 | this.password = hash_password(new_password); 47 | this.save(); 48 | }; 49 | 50 | module.exports.create = create; 51 | 52 | function create(data, callback){ 53 | if(data.password == ""){ 54 | console.log("Error: empty password got to user_db.create."); 55 | } 56 | 57 | var user = new User({ 58 | name: data.name || "", 59 | username: data.username || "", 60 | email: data.email || "", 61 | nickname: data.nickname || "", 62 | password: hash_password(data.password) 63 | }); 64 | 65 | user.save(function(err){ 66 | if(err){ 67 | console.log(err); 68 | } else { 69 | console.log("New user saved to mongo: " + user.username); 70 | } 71 | callback(); 72 | }); 73 | } 74 | 75 | /* 76 | 77 | Fetches data from mongodb 78 | Puts it in redis 79 | 80 | */ 81 | UserSchema.methods.login = function(cache_user_model, session_id){ 82 | var user = cache_user_model.create(); 83 | var public_id = user.get_public_id(); 84 | 85 | if(this.public_id == undefined || this.public_id == ""){ 86 | // Set public id for the first time 87 | this.public_id = public_id; 88 | this.save(); 89 | } else { 90 | // Use public id from db 91 | public_id = this.public_id; 92 | user.set_public_id(public_id); 93 | } 94 | 95 | user.set_permanent_user(this); 96 | user.set_nickname(this.nickname); 97 | user.set_session_id(session_id); 98 | user.set_public_id(public_id); 99 | user.save(); 100 | 101 | return public_id; 102 | }; 103 | 104 | var User = mongoose.model('User', UserSchema); 105 | 106 | /** 107 | This is supposed to hash passwords 108 | */ 109 | function hash_password(password){ 110 | var hashed = password_hash.generate(password); 111 | return hashed; 112 | } 113 | 114 | module.exports.exists_email = exists_email; 115 | 116 | /* 117 | Exists in mongo ? 118 | (Also gives the user if it exists) 119 | callback(exists: true | false, [user]) 120 | */ 121 | function exists_email(email, callback){ 122 | User.find({ email: email }, function(err, user){ 123 | if(err){console.log(err)}; 124 | 125 | if(user.length > 0){ 126 | callback(true, user[0]); 127 | } else { 128 | callback(false, user[0]); 129 | } 130 | }); 131 | } 132 | 133 | module.exports.exists_username = exists_username; 134 | 135 | /* 136 | Exists in mongo ? 137 | callback(exists: true | false) 138 | */ 139 | function exists_username(username, callback){ 140 | User.find({ username: username }, function(err, user){ 141 | if(err){console.log(err)}; 142 | if(user.length > 0){ 143 | callback(true); 144 | } else { 145 | callback(false); 146 | } 147 | }); 148 | } 149 | 150 | module.exports.get_user_by_id = get_user_by_id; 151 | 152 | function get_user_by_id(id, callback){ 153 | User.findById(id, function(err, user){ 154 | if(err){console.log(err)}; 155 | if(user != null){ 156 | callback(user); 157 | } else { 158 | callback(null); 159 | } 160 | }); 161 | } 162 | -------------------------------------------------------------------------------- /views/pricing.pug: -------------------------------------------------------------------------------- 1 | .text-center 2 | h1 3 | | Pricing 4 | p 5 | | Registered, Premium and Teacher Premium accounts are not yet available, but here is what we plan to offer*: 6 | p 7 | | *The features may change, livecalc.xyz is still in heavy development. 8 | p 9 | | If you are interested in trying these future premium accounts, sign up below! 10 | p.smaller 11 | | (Hurry up! Our first clients will receive a couple of free premium months!) 12 | br 13 | .text-center 14 | table.pricing-table 15 | tr 16 | th 17 | 18 | th 19 | | Free 20 | th 21 | | Free Registered* 22 | br 23 | span.coming-soon *Coming soon 24 | th 25 | | Premium* 26 | br 27 | span.coming-soon *Coming soon 28 | th 29 | | Teacher Premium* 30 | br 31 | span.coming-soon *Coming soon 32 | tr 33 | td.col-feature 34 | | Create public math 35 | br 36 | | sheets 37 | td.yes 38 | | yes 39 | td.yes 40 | | yes 41 | td.yes 42 | | yes 43 | td.yes 44 | | yes 45 | tr 46 | td.col-feature 47 | | Use chat 48 | td.yes 49 | | yes 50 | td.yes 51 | | yes 52 | td.yes 53 | | yes 54 | td.yes 55 | | yes 56 | tr 57 | td.col-feature 58 | | Plot functions 59 | td.yes 60 | | yes 61 | td.yes 62 | | yes 63 | td.yes 64 | | yes 65 | td.yes 66 | | yes 67 | tr 68 | td.col-feature 69 | | Lock sheets 70 | td.yes 71 | | yes 72 | td.yes 73 | | yes 74 | td.yes 75 | | yes 76 | td.yes 77 | | yes 78 | tr 79 | td.col-feature 80 | | Unlock sheets 81 | td 82 | | no 83 | td.yes 84 | | yes 85 | td.yes 86 | | yes 87 | td.yes 88 | | yes 89 | tr 90 | td.col-feature 91 | | Import student accounts 92 | td 93 | | no 94 | td 95 | | no 96 | td 97 | | no 98 | td.yes 99 | | yes 100 | tr 101 | td.col-feature 102 | | Online users / sheet 103 | td 104 | | up to 3 105 | td 106 | | up to 5 107 | td 108 | | up to 8 109 | td 110 | | up to 32 111 | tr 112 | td.col-feature 113 | | Active sheet / user 114 | td 115 | | 2 116 | td 117 | | 4 118 | td 119 | | 8 120 | td 121 | | 4 / student 122 | br 123 | span.smaller 8 for you 124 | tr 125 | td.col-feature 126 | | Facebook notifications 127 | td 128 | | no 129 | td.yes 130 | | yes 131 | td.yes 132 | | yes 133 | td.yes 134 | | yes 135 | tr 136 | td.col-feature 137 | | Email notifications 138 | td 139 | | no 140 | td.yes 141 | | yes 142 | td.yes 143 | | yes 144 | td.yes 145 | | yes 146 | tr 147 | td.col-feature 148 | | Visited sheet list 149 | td 150 | | no 151 | td.yes 152 | | yes 153 | td.yes 154 | | yes 155 | td.yes 156 | | yes 157 | tr 158 | td.col-feature 159 | | Create public templates 160 | td 161 | | no 162 | td.yes 163 | | yes 164 | td.yes 165 | | yes 166 | td.yes 167 | | yes 168 | tr 169 | td.col-feature 170 | | Upload images 171 | td 172 | | no 173 | td.yes 174 | | yes 175 | td.yes 176 | | yes 177 | td.yes 178 | | yes 179 | tr 180 | td.col-feature 181 | | Private sheets 182 | td 183 | | none 184 | td 185 | | 5 186 | td.yes 187 | | unlimited 188 | td.yes 189 | | unlimited 190 | tr 191 | td.col-feature 192 | | Price 193 | td 194 | | Free 195 | td 196 | | Free 197 | td 198 | span.bigger.strong $3 199 | | /month 200 | td 201 | span.bigger.strong $12 202 | | /month 203 | tr 204 | td.col-feature 205 | | 206 | td 207 | a(href="/new") 208 | button 209 | | Create a new sheet! 210 | td.receive-alert(colspan=3) 211 | | Receive email 212 | | alerts when these become available: 213 | br 214 | br 215 | form(action="/marketing/newsletter_signup", method="post") 216 | input(type="email", name="email", placeholder="email@email.com") 217 | button(type="submit" value="submit") 218 | | Sign up 219 | -------------------------------------------------------------------------------- /sass/style.scss: -------------------------------------------------------------------------------- 1 | $color_1: #3a260d; 2 | $color_2: #172a3a; 3 | $color_3: #729b79; 4 | $color_4: #dddddd; 5 | $color_5: #bacdb0; 6 | $color_6: #475b63; 7 | $color_7: #2e2c2f; 8 | 9 | $body_bg: #f3e8ee; 10 | 11 | $error_color: #733; 12 | 13 | @import 'base.scss'; 14 | 15 | @import 'livecalc-icons.scss'; 16 | 17 | @import 'pricing.scss'; 18 | 19 | @import 'landing.scss'; 20 | 21 | @import 'signup.scss'; 22 | 23 | @import 'sidebar.scss'; 24 | 25 | @import 'chat.scss'; 26 | 27 | @import 'livecalc.scss'; 28 | 29 | @import 'dashboard.scss'; 30 | 31 | @import 'account.scss'; 32 | 33 | /* static pages */ 34 | 35 | .static h1{ 36 | font-size:35px; 37 | line-height:50px; 38 | } 39 | 40 | 41 | /* server messages */ 42 | 43 | .server-message{ 44 | width:80%; 45 | margin:auto; 46 | padding:20px; 47 | margin-top:20px; 48 | } 49 | 50 | .server-message.positive{ 51 | background:$color_3; 52 | color:#fff; 53 | } 54 | 55 | .server-message.negative{ 56 | background:$error_color; 57 | color:#fff; 58 | } 59 | 60 | 61 | /* page-related stuff */ 62 | body{ 63 | background:$body_bg; 64 | font-family: sans-serif; 65 | font-family: 'Fira Sans'; 66 | padding:0; 67 | margin:0; 68 | width:100%; 69 | height:100%; 70 | } 71 | 72 | body a{ 73 | color:$color_2; 74 | } 75 | 76 | body, 77 | window{ 78 | overflow-x:hidden; 79 | max-width:100%; 80 | } 81 | 82 | h1{ 83 | font-size:25px; 84 | font-weight:300; 85 | margin-right:40px; 86 | margin-left:20px; 87 | } 88 | 89 | @import "header.scss"; 90 | 91 | template{ 92 | display:none; 93 | } 94 | 95 | button{ 96 | min-height:32px; 97 | margin-bottom:20px; 98 | margin-top:5px; 99 | border-radius:0; 100 | border:none; 101 | padding:5px; 102 | background:$color_3; 103 | font-size:14px; 104 | color:#fff; 105 | border-radius:2px; 106 | cursor:pointer; 107 | word-wrap: break-word; 108 | transition:all 0.3s; 109 | 110 | &:hover{ 111 | background:darken($color_3,10%); 112 | } 113 | } 114 | 115 | button:first-child{ 116 | margin-left:0; 117 | } 118 | 119 | input{ 120 | border:none; 121 | padding:0px 5px; 122 | background:#fff; 123 | box-shadow:0 0 3px rgba(0,0,0,0.2); 124 | height:30px; 125 | } 126 | 127 | h3{ 128 | font-family: 'Fira Sans'; 129 | font-weight:500; 130 | font-size:120%; 131 | margin-left:0; 132 | margin:5px 0; 133 | color:#000; 134 | } 135 | 136 | .doc{ 137 | background:#fff; 138 | margin-top:10px; 139 | padding:20px; 140 | padding-top:20px; 141 | } 142 | 143 | .doc h3{ 144 | margin-bottom:-10px; 145 | } 146 | 147 | h2{ 148 | color:$color_2; 149 | margin-bottom:6px; 150 | margin-top:0px; 151 | } 152 | 153 | p{ 154 | line-height:140%; 155 | } 156 | 157 | .doc p{ 158 | line-height:150%; 159 | } 160 | 161 | .doc code{ 162 | background:#eee; 163 | color:$color_1; 164 | display:inline-block; 165 | margin:5px 0; 166 | padding:0 5px; 167 | cursor:pointer; 168 | border-radius:3px; 169 | } 170 | 171 | .main-page{ 172 | margin-left:10px; 173 | margin-right:10px; 174 | } 175 | 176 | /* modals */ 177 | 178 | .modal-overlay{ 179 | position:fixed; 180 | z-index:1000; 181 | top:0; 182 | left:0; 183 | width:100%; 184 | height:100%; 185 | background:#333; 186 | background:rgba(0,0,0,0.3); 187 | } 188 | 189 | .modal{ 190 | width:400px; 191 | display:block; 192 | margin:auto; 193 | margin-top:100px; 194 | min-height:100px; 195 | background:#fff; 196 | color:#000; 197 | padding:20px; 198 | border:10px solid #eee; 199 | border-radius:3px; 200 | position:relative; 201 | z-index:1100; 202 | } 203 | 204 | @media all and (max-width:768px){ 205 | .modal{ 206 | width:90%; 207 | width:calc( 100% - 20px ); 208 | border:0px; 209 | margin-top:20px; 210 | padding:10px; 211 | margin-left:0; 212 | margin-right:0; 213 | border:none; 214 | position:relative; 215 | z-index:1000; 216 | } 217 | } 218 | 219 | .modal .buttons{ 220 | text-align:center; 221 | } 222 | 223 | .modal button{ 224 | margin-right: 15px; 225 | } 226 | 227 | .fullscreen{ 228 | background:lighten($body_bg, 10%); 229 | width:100%; 230 | height:100%; 231 | position:fixed; 232 | top:0; 233 | left:0; 234 | z-index:1000; 235 | text-align:center; 236 | } 237 | 238 | .fullscreen-header{ 239 | background:#eee; 240 | margin-bottom:5px; 241 | } 242 | 243 | .fullscreen-title{ 244 | margin-top:25px; 245 | text-align:left; 246 | margin-left:30px; 247 | } 248 | 249 | .fullscreen .close-button{ 250 | margin-right:20px; 251 | margin-top:20px; 252 | } 253 | 254 | .function-plot { 255 | font-size: 10px; 256 | } 257 | 258 | .plot-interact{ 259 | margin-top: 30px; 260 | } 261 | 262 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var express = require("express"); 2 | var body_parser = require("body-parser"); 3 | var app = express(); 4 | var http = require("http").Server(app); 5 | var io = require("socket.io")(http); 6 | var sass_middleware = require("node-sass-middleware"); 7 | var livecalc = require("./livecalc"); 8 | var sheet_db = require("./sheet_db"); 9 | var cache_user_model = require("./cache_user_model"); 10 | var chat_db = require("./chat_db"); 11 | var stats = require("./stats"); 12 | var cookie_utils = require("./user/cookie_utils"); 13 | 14 | livecalc.set_globals(io, sheet_db, chat_db, stats, cache_user_model); 15 | 16 | app.use(body_parser.json()); 17 | app.use(body_parser.urlencoded({extended: true})); 18 | 19 | // Add some user info for render 20 | app.use(function(req, res, next){ 21 | res.locals.page = "not-specified"; 22 | get_user_model(req, function(user){ 23 | res.locals.user = user || null; 24 | if(user == null){ 25 | // not logged in 26 | res.locals.logged_in = false; 27 | } else { 28 | // logged in 29 | res.locals.logged_in = true; 30 | } 31 | next(); 32 | }); 33 | }); 34 | 35 | /* Stylesheets */ 36 | app.use(sass_middleware({ 37 | src: "./sass", 38 | dest: "./public/css", 39 | debug: false, 40 | sourceMap: true, 41 | outputStyle: "compressed", 42 | prefix: "/css" 43 | })); 44 | 45 | // Serve static files 46 | app.use(express.static('public')); 47 | 48 | app.locals.pretty = true; 49 | 50 | // Views 51 | app.set('views', './views') 52 | app.set('view engine', 'pug'); 53 | 54 | app.get('/', function (req, res) { 55 | stats.log_visit("landing-page"); 56 | res.render('base',{page: "landing"}); 57 | }); 58 | 59 | // All the user pages (account, login, etc.) 60 | var user_pages = require("./user/user_pages")(app, cache_user_model) 61 | 62 | // All the marketing pages (pricing) 63 | var marketing_pages = require("./marketing/marketing")(app, stats) 64 | 65 | function is_logged_in(req, callback){ 66 | var session_id = cookie_utils.cookie_get_id(req) || ""; 67 | 68 | if(session_id == ""){ 69 | callback(false); 70 | } 71 | 72 | cache_user_model.temp_exists(session_id, function(exists){ 73 | if(exists){ 74 | return callback(true); 75 | } else { 76 | return callback(false); 77 | } 78 | }); 79 | } 80 | 81 | /* 82 | Make sure user is logged in before calling this. 83 | callback(cached_user) 84 | */ 85 | function get_user_model(req, callback){ 86 | var session_id = cookie_utils.cookie_get_id(req) || ""; 87 | 88 | is_logged_in(req, function(logged_in){ 89 | if(logged_in){ 90 | user = cache_user_model.cached_user(session_id); 91 | user.fetch(function(){ 92 | callback(user); 93 | }); 94 | } else { 95 | callback(null); 96 | } 97 | }); 98 | } 99 | 100 | app.get("/sheet/:id",function (req, res) { 101 | var sheet_id = req.params.id; 102 | var logged_in = res.locals.logged_in; 103 | var user = res.locals.user || null; 104 | 105 | // Todo: unhardcode domain and protocol 106 | var share_url = 107 | "https://livecalc.xyz/sheet/" + 108 | sheet_id; 109 | 110 | res.locals.share_url = share_url; 111 | 112 | // See if namespace is already in memory 113 | // Else we check in DB 114 | if(livecalc.namespaces.indexOf(sheet_id) <= -1){ 115 | sheet_db.exists(sheet_id, function(exists){ 116 | // Maybe then it exists in redis 117 | if(exists){ 118 | livecalc.new_namespace(sheet_id); 119 | res.render('base',{ 120 | in_sheet: true, 121 | page: "sheet" 122 | }); 123 | 124 | // For "recently visited sheets" 125 | if(user != null){ 126 | user.visit_sheets(sheet_id); 127 | } 128 | } else { 129 | // Else, sheet not found 130 | res.status(404).send('Not Found'); 131 | } 132 | }); 133 | } else { 134 | // Sheet exists 135 | res.render('base',{ 136 | in_sheet: true, 137 | page: "sheet" 138 | }); 139 | } 140 | }); 141 | 142 | app.get("/copy/:id",function (req, res) { 143 | var sheet_id = req.params.id; 144 | var new_id = require("./tokens").generate_token(); 145 | 146 | sheet_db.get_sheet(sheet_id, function(data){ 147 | data.params.locked = false; 148 | sheet_db.store_sheet(new_id, data); 149 | res.redirect("/sheet/"+new_id); 150 | }); 151 | }); 152 | 153 | app.get("/new",function (req, res) { 154 | // Todo: verify if a sheet already has same token 155 | // Not so probable... 156 | // Nope, new sheet 157 | stats.new_sheet(); 158 | 159 | var token = require("./tokens").generate_token(); 160 | 161 | var model = require("./sheet_model").create(); 162 | 163 | sheet_db.store_sheet(token, model.get_sheet()); 164 | 165 | res.redirect("/sheet/"+token); 166 | }); 167 | 168 | http.listen(3000); 169 | -------------------------------------------------------------------------------- /cache_user_model.js: -------------------------------------------------------------------------------- 1 | /* cache_user_model.js */ 2 | 3 | /* todo: untangle this and user_db.js */ 4 | 5 | var db = require("./user_db"); 6 | var user_cache = require("./user_cache"); 7 | 8 | module.exports = {}; 9 | 10 | module.exports.db = db; 11 | 12 | module.exports.cached_user = cached_user; 13 | 14 | function cached_user(session_id, public_id){ 15 | var exports = {}; 16 | 17 | var data = { 18 | public_id: public_id || "", 19 | session_id: session_id || "", 20 | nickname: "Anonymous", 21 | username: "", 22 | permanent_id: null, 23 | recent_sheets: [] 24 | }; 25 | 26 | var permanent_user = null; 27 | 28 | exports.get_permanent_user = function(){ 29 | return permanent_user; 30 | }; 31 | 32 | exports.set_permanent_user = set_permanent_user; 33 | 34 | function set_permanent_user(new_permanent_user){ 35 | permanent_user = new_permanent_user; 36 | data.username = permanent_user.username; 37 | data.email = permanent_user.email; 38 | data.nickname = permanent_user.nickname; 39 | data.permanent_id = permanent_user.id; 40 | data.recent_sheets = permanent_user.recent_sheets; 41 | } 42 | 43 | exports.visit_sheets = visit_sheets; 44 | 45 | /* 46 | Can be an array or a single element 47 | */ 48 | function visit_sheets(sheet_ids){ 49 | user_cache.visit_sheet(data.permanent_id, sheet_ids); 50 | } 51 | 52 | exports.recently_visited_sheets = recently_visited_sheets; 53 | 54 | function recently_visited_sheets(callback){ 55 | user_cache.recently_visited_sheets( 56 | data.permanent_id, callback 57 | ); 58 | } 59 | 60 | exports.fetch_permanent_user = fetch_permanent_user; 61 | 62 | /* 63 | Find user in mongo 64 | to prepare for update 65 | */ 66 | function fetch_permanent_user(callback){ 67 | if(data.permanent_id == null){ 68 | console.log("Error: cannot fetch permanent user. "+ 69 | "permanent_id is not known."); 70 | } 71 | 72 | db.get_user_by_id(data.permanent_id, function(user){ 73 | if(user == null){ 74 | console.log("Error: permanent id not linked to a user."); 75 | return; 76 | } 77 | set_permanent_user(user); 78 | callback(); 79 | }); 80 | } 81 | 82 | /* 83 | Save in mongo 84 | */ 85 | exports.save_permanent = function(){ 86 | permanent_user.save(); 87 | }; 88 | 89 | /* 90 | Save in cache 91 | */ 92 | exports.save = function(){ 93 | user_cache.store_user(data.session_id, data); 94 | }; 95 | 96 | /* 97 | Fetch from cache 98 | callback() 99 | */ 100 | exports.fetch = function(callback){ 101 | user_cache.get_user( data.session_id, function(from_cache){ 102 | data = from_cache; 103 | callback(); 104 | }); 105 | }; 106 | 107 | /* 108 | Should be only data safe for frontend 109 | */ 110 | exports.get_public_data = function(){ 111 | return { 112 | public_id: data.public_id, 113 | nickname: data.nickname, 114 | focus: -1 115 | } 116 | }; 117 | 118 | exports.get_public_id = function(){ 119 | return data.public_id; 120 | }; 121 | 122 | exports.set_public_id = function(new_id){ 123 | data.public_id = new_id; 124 | }; 125 | 126 | exports.get_session_id = function(){ 127 | return data.session_id; 128 | }; 129 | 130 | exports.set_session_id = function(new_id){ 131 | data.session_id = new_id; 132 | }; 133 | 134 | exports.set_nickname = function(new_nickname){ 135 | if(permanent_user != null){ 136 | permanent_user.nickname = new_nickname; 137 | permanent_user.save(); 138 | } 139 | data.nickname = new_nickname; 140 | }; 141 | 142 | exports.get_nickname = function(){ 143 | return data.nickname; 144 | }; 145 | 146 | exports.set_username = function(new_username){ 147 | if(permanent_user != null){ 148 | permanent_user.username = new_username; 149 | permanent_user.save(); 150 | } 151 | data.username = new_username; 152 | }; 153 | 154 | exports.get_username = function(){ 155 | return data.username; 156 | }; 157 | 158 | exports.set_email = function(new_email){ 159 | if(permanent_user != null){ 160 | permanent_user.email = new_email; 161 | permanent_user.save(); 162 | } 163 | data.email = new_email; 164 | }; 165 | 166 | exports.get_email = function(){ 167 | return data.email; 168 | }; 169 | 170 | return exports; 171 | } 172 | 173 | module.exports.create = function(){ 174 | var session_id = require("./tokens").generate_token(6); 175 | var public_id = require("./tokens").generate_token(6); 176 | return cached_user(session_id, public_id); 177 | }; 178 | 179 | module.exports.temp_exists = function(id, callback){ 180 | user_cache.exists(id, callback); 181 | }; 182 | 183 | module.exports.logout = function(session_id){ 184 | user_cache.logout(session_id); 185 | }; 186 | -------------------------------------------------------------------------------- /user/user_pages.js: -------------------------------------------------------------------------------- 1 | /* Handles request to user pages */ 2 | 3 | var cookie_utils = require("./cookie_utils"); 4 | 5 | module.exports = function(app, cache_user_model){ 6 | app.get('/signup', function (req, res) { 7 | if(!res.locals.logged_in){ 8 | // If not logged in 9 | res.render('base',{page: "signup"}); 10 | } else { 11 | // If logged in, redirect to user dashboard 12 | res.redirect("/dashboard"); 13 | } 14 | }); 15 | 16 | app.get('/account', function (req, res) { 17 | if(res.locals.logged_in){ 18 | // If logged in 19 | res.render('base',{page: "account"}); 20 | } else { 21 | // Else, go to signup 22 | res.redirect("/signup"); 23 | } 24 | }); 25 | 26 | function get_ip(req){ 27 | // http://stackoverflow.com/questions/10849687/express-js-how-to-get-remote-client-address 28 | var ip = req.headers['x-forwarded-for'] || 29 | req.connection.remoteAddress; 30 | 31 | return ip; 32 | } 33 | 34 | app.get('/account-username',function(req, res){ 35 | res.redirect("/account"); 36 | }); 37 | 38 | app.post('/account-username', function (req, res) { 39 | var user = res.locals.user || null; 40 | 41 | if(user == null){ 42 | res.redirect("/signup"); 43 | return; 44 | } 45 | 46 | user.fetch_permanent_user(function(){ 47 | var username = req.body.username || ""; 48 | var user_db = cache_user_model.db; 49 | 50 | // Until not true, 51 | var success = true; 52 | var errors = []; 53 | 54 | // Username length 55 | if(username.length < 6){ 56 | success = false; 57 | errors.push("Username is too short."); 58 | } 59 | 60 | // Username length 61 | if(username.length > 40){ 62 | success = false; 63 | errors.push("Username is too long."); 64 | } 65 | 66 | // Username format 67 | if(!username.match(/^[A-Za-z0-9]*$/)){ 68 | success = false; 69 | errors.push("Username contains invalid characters."); 70 | } 71 | 72 | // Check username for existence 73 | user_db.exists_username(username, function(exists){ 74 | if(exists){ 75 | success = false; 76 | errors.push("This username is already used by someone."); 77 | } 78 | 79 | // If still successful 80 | if(success == true){ 81 | // Save user to mongo db 82 | user.set_username(username); 83 | account_render(req, res, true, ["Your username was changed"]); 84 | } else { 85 | account_render(req, res, false, errors); 86 | } 87 | }); 88 | }); 89 | }); 90 | 91 | app.get('/account-password',function(req, res){ 92 | res.redirect("/account"); 93 | }); 94 | 95 | app.post('/account-password', function (req, res) { 96 | var user = res.locals.user || null; 97 | var current_password = req.body.current_password || ""; 98 | var new_password = req.body.new_password || ""; 99 | var new_password_repeat = req.body.new_password_repeat || ""; 100 | 101 | if(user == null){ 102 | res.redirect("/signup"); 103 | return; 104 | } 105 | 106 | user.fetch_permanent_user(function(){ 107 | var perm = user.get_permanent_user(); 108 | 109 | if(!perm.verify_password(current_password)){ 110 | account_render(req, res, false, ["Wrong password"]); 111 | return; 112 | } 113 | 114 | // Password length 115 | if(new_password.length < 6){ 116 | account_render(req, res, false, ["Password is too short."]); 117 | return; 118 | } 119 | 120 | // Password & password repead match 121 | if(new_password_repeat != new_password){ 122 | account_render(req, res, false, 123 | ["New passwords do not match."]); 124 | return; 125 | } 126 | 127 | perm.update_password(new_password); 128 | account_render(req, res, true, "Password updated!"); 129 | }); 130 | }); 131 | 132 | app.get('/account-email',function(req, res){ 133 | res.redirect("/account"); 134 | }); 135 | 136 | app.post('/account-email', function (req, res) { 137 | var user = res.locals.user || null; 138 | 139 | if(user == null){ 140 | res.redirect("/signup"); 141 | return; 142 | } 143 | 144 | var validator = require("email-validator"); 145 | var email = req.body.email || ""; 146 | 147 | // Email validation 148 | if(!validator.validate(email)){ 149 | // Nope 150 | account_render(req, res, false, ["Email address is not valid."]); 151 | return; 152 | } 153 | 154 | user.fetch_permanent_user(function(){ 155 | var user_db = cache_user_model.db; 156 | 157 | // Check email 158 | user_db.exists_email(email, function(exists){ 159 | if(!exists){ 160 | // Ok! 161 | user.set_email(email); 162 | account_render(req, res, true, 163 | "Your email address was updated."); 164 | } else { 165 | // Nope 166 | account_render(req, res, false, ["Email " + 167 | email 168 | + " already in use."]); 169 | return; 170 | } 171 | }); 172 | }); 173 | }); 174 | 175 | function account_render(req, res, success, message){ 176 | if(success != ""){ 177 | res.render('base',{ 178 | page: "account", 179 | positive_message: true, 180 | message: message 181 | }); 182 | } else { 183 | var ip = get_ip(req); 184 | console.log("unsuccessful account operation: " + ip); 185 | 186 | res.render('base',{ 187 | page: "account", 188 | negative_message: true, 189 | message: message.join(" ") 190 | }); 191 | } 192 | } 193 | 194 | app.get('/login', function (req, res) { 195 | if(!res.locals.logged_in){ 196 | // If not logged in 197 | res.render('base',{page: "login"}); 198 | } else { 199 | // If logged in, redirect to user dashboard 200 | res.redirect("/dashboard"); 201 | return; 202 | } 203 | }); 204 | 205 | 206 | app.get('/logout', function (req, res){ 207 | var session_id = cookie_utils.cookie_get_id(req); 208 | cookie_utils.cookie_send_id(res, ""); 209 | 210 | // So that the frontend does not think user 211 | // is logged in. 212 | res.locals.user = undefined; 213 | res.locals.logged_in = false; 214 | 215 | cache_user_model.logout(session_id); 216 | 217 | res.render('base',{ 218 | page: "login", 219 | positive_message: true, 220 | message: "You successfully logged out. Come again soon!" 221 | }); 222 | }); 223 | 224 | app.post('/login', function (req, res){ 225 | if(!res.locals.logged_in){ 226 | // If not logged in 227 | post_login_form(req, res); 228 | } else { 229 | // If logged in, redirect to user dashboard 230 | res.redirect("/dashboard"); 231 | return; 232 | } 233 | }); 234 | 235 | function post_login_form(req, res){ 236 | var email = req.body.email || ""; 237 | var password = req.body.password || ""; 238 | var user_db = cache_user_model.db; 239 | 240 | // Check email 241 | user_db.exists_email(email, function(exists, user){ 242 | if(!exists){ 243 | // Nope! 244 | render(false); 245 | } else { 246 | if(user.verify_password(password)){ 247 | // User exists, has good password 248 | var session_id = require("../tokens").generate_token(20); 249 | user.login(cache_user_model, session_id); 250 | 251 | cookie_utils.cookie_send_id(res, session_id); 252 | render(true); 253 | } else { 254 | // User exists, but wrong password 255 | render(false); 256 | } 257 | } 258 | }); 259 | 260 | function render(success){ 261 | if(success){ 262 | res.redirect("/dashboard"); 263 | return; 264 | } else { 265 | var ip = get_ip(req); 266 | 267 | console.log("unsuccessful login attempt: " + ip); 268 | 269 | res.render('base',{ 270 | page: "login", 271 | negative_message: true, 272 | message: "Wrong username/password combination." 273 | }); 274 | } 275 | } 276 | } 277 | 278 | app.get('/dashboard', function (req, res) { 279 | // Se if user is logged in and get data 280 | if(!res.locals.logged_in){ 281 | // Not logged in 282 | res.redirect("/login"); 283 | return; 284 | } else { 285 | var user = res.locals.user || null; 286 | 287 | // Find recently visited sheets 288 | user.recently_visited_sheets(function(sheets){ 289 | // Render page 290 | res.render('base',{ 291 | page: "dashboard", 292 | recent_sheets: sheets 293 | }); 294 | }); 295 | } 296 | }); 297 | 298 | app.post('/signup', function (req, res) { 299 | var username = req.body.username || ""; 300 | var email = req.body.email || ""; 301 | var password = req.body.password || ""; 302 | var user_db = cache_user_model.db; 303 | 304 | // Until not true, 305 | var success = true; 306 | var errors = []; 307 | 308 | var validator = require("email-validator"); 309 | 310 | // Email validation 311 | if(!validator.validate(email)){ 312 | success = false; 313 | errors.push("Email address is not valid."); 314 | } 315 | 316 | // Username length 317 | if(username.length < 6){ 318 | success = false; 319 | errors.push("Username is too short."); 320 | } 321 | 322 | // Username length 323 | if(username.length > 40){ 324 | success = false; 325 | errors.push("Username is too long."); 326 | } 327 | 328 | // Username format 329 | if(!username.match(/^[A-Za-z0-9]*$/)){ 330 | success = false; 331 | errors.push("Username contains invalid characters."); 332 | } 333 | 334 | // Password length 335 | if(password.length < 6){ 336 | success = false; 337 | errors.push("Password is too short."); 338 | } 339 | 340 | // Check email 341 | user_db.exists_email(email, function(exists){ 342 | if(exists){ 343 | success = false; 344 | errors.push("This email is already in use."); 345 | } 346 | 347 | // Check username 348 | user_db.exists_username(username, function(exists){ 349 | if(exists){ 350 | success = false; 351 | errors.push("This username is already used by someone."); 352 | } 353 | 354 | // If still successful 355 | if(success == true){ 356 | // Save user to mongo db 357 | user_db.create({ 358 | username: username, 359 | email: email, 360 | password: password 361 | }, function(){ 362 | render(success, errors); 363 | }); 364 | } else { 365 | render(success, errors); 366 | } 367 | }); 368 | }); 369 | 370 | function render(success, errors){ 371 | if(success){ 372 | res.redirect("/dashboard"); 373 | return; 374 | } else { 375 | var ip = get_ip(req); 376 | 377 | console.log("unsuccessful account creation attempt: " + ip); 378 | 379 | 380 | res.render('base',{ 381 | page: "signup", 382 | negative_message: true, 383 | message: errors.join(" ") 384 | }); 385 | } 386 | } 387 | }); 388 | }; 389 | -------------------------------------------------------------------------------- /livecalc.js: -------------------------------------------------------------------------------- 1 | /* livecalc.js */ 2 | 3 | var cookie = require('cookie'); 4 | var user_cache = require('./user_cache'); 5 | 6 | var sheet_counter = require('./sheet_counter'); 7 | 8 | var site_user_count = 0; 9 | 10 | /* old globals */ 11 | var io, sheet_db, chat_db, stats, cache_user_model; 12 | 13 | var namespaces = []; 14 | 15 | module.exports = {}; 16 | 17 | module.exports.namespaces = namespaces; 18 | 19 | /* Set socket.io et al. */ 20 | module.exports.set_globals = function ( 21 | new_io, 22 | new_sheet_db, 23 | new_chat_db, 24 | new_stats, 25 | new_cache_user_model ){ 26 | 27 | io = new_io; 28 | sheet_db = new_sheet_db; 29 | chat_db = new_chat_db; 30 | stats = new_stats; 31 | cache_user_model = new_cache_user_model; 32 | }; 33 | 34 | 35 | /** 36 | Callback is supposed to render page 37 | */ 38 | module.exports.new_namespace = function(namespace){ 39 | var nsp = io.of("/"+namespace); 40 | 41 | livecalc(namespace, nsp); 42 | } 43 | 44 | /** 45 | Sidebar chat 46 | 47 | This is the code that listens to everything related to the chat. 48 | */ 49 | module.exports.livechat = livechat; 50 | function livechat(namespace, nsp, socket, user){ 51 | var user; 52 | 53 | var exports = {}; 54 | 55 | exports.set_user = function(new_user){ 56 | user = new_user; 57 | }; 58 | 59 | socket.on("load more messages",function(last_sent){ 60 | chat_db.get_conv(namespace,function(data){ 61 | socket.emit("past messages", data); 62 | }); 63 | }); 64 | 65 | socket.on("new message", function(data){ 66 | if(user == undefined){ 67 | return; 68 | } 69 | 70 | var data = { 71 | message: data.message, 72 | sender: user.get_nickname(), 73 | public_id: user.get_public_id() 74 | }; 75 | 76 | chat_db.add_message(namespace, data); 77 | 78 | socket.broadcast.emit("new message", data); 79 | socket.emit("own message", data); 80 | }); 81 | 82 | return exports; 83 | } 84 | 85 | /* 86 | callback(success) 87 | */ 88 | module.exports.livecalc = livecalc; 89 | function livecalc(namespace, nsp){ 90 | var model = require("./sheet_model").create(); 91 | var counter = sheet_counter.Counter(); 92 | 93 | namespaces.push(namespace); 94 | 95 | // Check if sheet exists 96 | // Then load it and serve normally 97 | sheet_db.exists(namespace, function(exists){ 98 | if(exists){ 99 | sheet_db.get_sheet(namespace, function(data){ 100 | model.set_sheet(data); 101 | listen(); 102 | }) 103 | } else { 104 | console.log("Someone tries to access namespace: "+namespace+ 105 | " but it does not exist. This should not happen."); 106 | } 107 | }); 108 | 109 | function listen(){ 110 | /* 111 | Array of users used to create focus index 112 | 113 | note: don't rely on a value being in there. 114 | 115 | for(i in users) == ok 116 | users[my_id] == not ok, don't do that! 117 | (it might not exist) 118 | 119 | */ 120 | var users = {}; 121 | 122 | nsp.on("connection", function(socket){ 123 | var cookie_val = socket.handshake.headers['cookie']; 124 | var session_id = cookie.parse(cookie_val || '').session_id || ""; 125 | var registered = false; 126 | var user; 127 | var public_id; 128 | 129 | function temp_user(){ 130 | // Temporary user 131 | user = cache_user_model.create(); 132 | public_id = user.get_public_id(); 133 | 134 | // Generate a temporary session id 135 | // (That will not be saved, even to redis) 136 | session_id = user.get_session_id(); 137 | users[session_id] = user.get_public_data(); 138 | } 139 | 140 | if(session_id != ""){ 141 | // User says "im logged in" 142 | // See if the user is actually in redis 143 | cache_user_model.temp_exists(session_id, function(exists){ 144 | if(exists){ 145 | // User actually logged in 146 | registered = true; 147 | user = cache_user_model.cached_user(session_id); 148 | user.fetch(function(){ 149 | public_id = user.get_public_id(); 150 | users[session_id] = user.get_public_data(); 151 | chat.set_user(user); 152 | send_user_data(); 153 | }); 154 | } else { 155 | // User had a session_id, but it 156 | // is not in db. (expired/never existed) 157 | // TODO: inform user he is not connected. 158 | console.log( 159 | "Attempt to login with bad cookie token." 160 | ); 161 | 162 | temp_user(); 163 | 164 | // Send temp data 165 | send_user_data(); 166 | } 167 | }); 168 | } else { 169 | // User is not logged in 170 | temp_user(); 171 | // Still send temp data 172 | send_user_data(); 173 | } 174 | 175 | send_focus_index(); 176 | 177 | // Rate limiting. 178 | // while registered users are not managed, 179 | // leave this to 5 180 | if(counter.get() >= 5){ 181 | socket.emit("too many users"); 182 | return; 183 | } 184 | 185 | stats.new_sheet_visit(namespace); 186 | 187 | stats.get_sheet_visits(namespace, function(num){ 188 | nsp.emit("sheet visit count", num); 189 | }); 190 | 191 | 192 | counter.plus("anon"); 193 | site_user_count++; 194 | 195 | console.log( 196 | "connection - " + 197 | site_user_count + 198 | " users in site, " + 199 | counter.get("anon") + 200 | " users in sheet " + 201 | namespace 202 | ); 203 | 204 | nsp.emit("user count", counter.get("anon")); 205 | 206 | if(users[session_id] != undefined){ 207 | users[session_id].focus = -1; 208 | } 209 | 210 | var chat = livechat(namespace, nsp, socket, user); 211 | 212 | /* 213 | Build array containing array of nicknames 214 | of user focussing on each cell 215 | 216 | [ 217 | ["Paul","Anonymous"], 218 | [], 219 | ["George"] 220 | ] 221 | 222 | Goal: show the users who's editing what. 223 | 224 | */ 225 | function send_focus_index(){ 226 | var fi = []; 227 | 228 | for(var i = 0; i < model.get_length(); i++){ 229 | fi.push([]); 230 | } 231 | 232 | if(!model.is_locked()){ 233 | for(var i in users){ 234 | var user = users[i]; 235 | if(user == undefined){ 236 | continue; 237 | } 238 | if(user.focus != undefined && user.focus != -1){ 239 | fi[user.focus].push(user.nickname); 240 | } 241 | } 242 | } 243 | nsp.emit("focus index", fi); 244 | } 245 | 246 | // Send sheet to user 247 | socket.emit("sheet", JSON.stringify(model.get_sheet())); 248 | 249 | socket.on("set nickname",function(data){ 250 | // Prevent XSS 251 | // Prevent injection of whatever dom element here 252 | // by allowing only certain characters 253 | var nickname = data.nickname.replace(/[^A-Za-z0-9\-]/g,""); 254 | 255 | if(registered){ 256 | user.fetch_permanent_user(function(){ 257 | user.set_nickname(nickname); 258 | // Store in redis 259 | user.save(); 260 | // And in mongo 261 | user.save_permanent(); 262 | 263 | after(); 264 | }); 265 | } else { 266 | user.set_nickname(nickname); 267 | after(); 268 | } 269 | 270 | function after(){ 271 | users[session_id].nickname = nickname; 272 | send_user_data(); 273 | send_focus_index(); 274 | } 275 | }); 276 | 277 | function send_user_data(){ 278 | socket.emit("user data", user.get_public_data()); 279 | } 280 | 281 | socket.on("set focus",function(data){ 282 | if(model.is_locked()){ 283 | return; 284 | } 285 | 286 | var index = data.index; 287 | users[session_id].focus = index; 288 | 289 | send_focus_index(); 290 | }); 291 | 292 | 293 | socket.on("lock sheet",function(data){ 294 | // Don't lock demo 295 | if(namespace == "demo"){ 296 | return; 297 | } 298 | 299 | // Already locked? 300 | if(model.is_locked()){ 301 | return; 302 | } 303 | 304 | model.lock(); 305 | save(true); 306 | 307 | send_focus_index(); 308 | 309 | socket.emit("sheet locked", { 310 | initiator: users[session_id].nickname 311 | }); 312 | }); 313 | 314 | /* 315 | When a user submits a cell by pressing enter 316 | or "go", the model is updated and the info 317 | is sent to other users, overwriting current value 318 | for all users. 319 | */ 320 | socket.on("definitive edit", function(data){ 321 | if(!model.is_locked()){ 322 | model.edit(data); 323 | socket.broadcast.emit("definitive edit", data); 324 | save(); 325 | } 326 | }); 327 | 328 | /* 329 | Live edits are not definitive 330 | They are just there to show what other users 331 | are typing 332 | */ 333 | socket.on("live edit", function(data){ 334 | if(!model.is_locked()){ 335 | socket.broadcast.emit("live edit", data); 336 | } 337 | }); 338 | 339 | socket.on("insert cell", function(data){ 340 | if(!model.is_locked()){ 341 | model.insert_cell(data); 342 | socket.broadcast.emit("insert cell", data); 343 | save(); 344 | } 345 | }); 346 | 347 | socket.on("delete cell", function(data){ 348 | if(!model.is_locked()){ 349 | model.remove(data); 350 | 351 | save(); 352 | socket.broadcast.emit("delete cell", data); 353 | } 354 | }); 355 | 356 | socket.on("disconnect",function(socket){ 357 | counter.minus("anon"); 358 | site_user_count--; 359 | 360 | console.log( 361 | "disconnection - " + 362 | site_user_count + 363 | " users in site, " + 364 | counter.get("anon") + 365 | " users in sheet " + 366 | namespace 367 | ); 368 | 369 | nsp.emit("user count", counter.get("anon")); 370 | 371 | if(registered){ 372 | // Save user in memory 373 | user.save(); 374 | } 375 | 376 | /* 377 | Delete user from memory. 378 | 379 | What if user is in 2 tabs: 380 | If the user is there 2 times in 2 tabs, 381 | it will be recreated after the user sends back it's 382 | focus. So there is no problem in deleting. 383 | 384 | This is necessary to avoid ending up with enormous 385 | amounts of users in this array. 386 | */ 387 | delete users[session_id]; 388 | send_focus_index(); 389 | }); 390 | }); 391 | 392 | function save(even_if_locked){ 393 | var even_if_locked = even_if_locked || false; 394 | if(!model.is_locked() || even_if_locked){ 395 | sheet_db.store_sheet(namespace, model.get_sheet()); 396 | } 397 | } 398 | } 399 | } 400 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | {one line to give the program's name and a brief idea of what it does.} 635 | Copyright (C) {year} {name of author} 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | {project} Copyright (C) {year} {fullname} 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | -------------------------------------------------------------------------------- /public/main.js: -------------------------------------------------------------------------------- 1 | /* 2 | Shortcut to document.querySelectorAll 3 | */ 4 | function qsa(sel){ 5 | return document.querySelectorAll(sel); 6 | } 7 | 8 | /* 9 | qsa, but on an element 10 | */ 11 | function subqsa(el,sel){ 12 | return el.querySelectorAll(sel); 13 | } 14 | 15 | /* 16 | Hide an element 17 | */ 18 | function hide(el){ 19 | el.classList.add("hidden"); 20 | } 21 | 22 | /* 23 | Determine if hidden 24 | */ 25 | function hidden(el){ 26 | return el.classList.contains("hidden"); 27 | } 28 | 29 | 30 | /* 31 | Show an element 32 | */ 33 | function show(el){ 34 | el.classList.remove("hidden"); 35 | } 36 | 37 | 38 | /* 39 | Load a template 40 | 41 | returns the HTML 42 | */ 43 | function load_template(name){ 44 | var el = qsa("template[name="+name+"]")[0]; 45 | 46 | if(el == undefined){ 47 | console.error("Template "+name+" does not exist."); 48 | } 49 | 50 | var content = el.innerHTML; 51 | 52 | var params = el.getAttribute("data-params"); 53 | 54 | if(params == "" || params == null){ 55 | params = []; 56 | } else { 57 | params = params.split(","); 58 | } 59 | 60 | return { 61 | content: content, 62 | params: params 63 | }; 64 | } 65 | 66 | /* 67 | finds template-instances 68 | 69 | replaces handlebars by data- attributes 70 | 71 | {{meow}} will be replaced by attribute data-meow 72 | 73 | (Sort of a preprocessor) 74 | */ 75 | function instanciator(el){ 76 | var instances = subqsa(el,"template-instance"); 77 | 78 | for(var i = 0; i < instances.length; i++){ 79 | var instance = instances[i]; 80 | var name = instance.getAttribute("data-name"); 81 | var template = load_template(name); 82 | var content = template.content; 83 | var params = template.params; 84 | 85 | for(var j = 0; j < params.length; j++){ 86 | var attr = "data-"+params[j]; 87 | var value = instance.getAttribute(attr); 88 | 89 | // Sanitize value to avoid XSS 90 | value = value.replace(/[^A-Za-z0-9\-\.\_\: ]/g,""); 91 | var attr = attr.replace(/^data-/,"") 92 | var handle = "{{"+attr+"}}"; 93 | 94 | content = content.replace(handle,value); 95 | } 96 | 97 | instance.innerHTML = content; 98 | } 99 | } 100 | 101 | /* 102 | Create an instance of a template and put it in to_el, 103 | replacing the content 104 | Improvement idea: manage parameters and use this in instanciator 105 | instead of current code. 106 | */ 107 | function render(template_name, to_el){ 108 | var template = load_template(template_name).content; 109 | 110 | var to_el = to_el || dom("
"); 111 | 112 | to_el.innerHTML = template; 113 | instanciator(to_el); 114 | 115 | return to_el; 116 | } 117 | 118 | /* 119 | Load a script 120 | Returns the content 121 | */ 122 | function load_script(name){ 123 | var content = qsa("script[name="+name+"]")[0].innerHTML; 124 | return content; 125 | } 126 | 127 | /* 128 | Create a dom element 129 | */ 130 | function dom(html){ 131 | var node = document.createElement("div"); 132 | node.innerHTML = html; 133 | return node.children[0]; 134 | } 135 | 136 | /* 137 | Make something disappear "smoothly" 138 | */ 139 | function disappear(el, effect){ 140 | appear(el, effect, true); 141 | } 142 | 143 | /* 144 | Make something appear "smoothly" 145 | */ 146 | function appear(el, effect, reverse){ 147 | var reverse = reverse || false; 148 | var effect = effect || "scale up"; 149 | 150 | var effects = { 151 | "scale up": { 152 | max: 3, 153 | begin: function(el){ 154 | el.style.transform = "scale(0.0)"; 155 | }, 156 | end: function(el){ 157 | el.style.transform = ""; 158 | }, 159 | step: function(el,step,max){ 160 | var ratio = step / max; 161 | 162 | if(reverse){ 163 | ratio = 1 - ratio; 164 | } 165 | 166 | el.style.opacity = "0.0"; 167 | el.style.opacity = 1.0 - ratio; 168 | el.style.transform = "scale("+(1.0 - ratio)+")"; 169 | } 170 | }, 171 | "from top": { 172 | max: 6, 173 | begin: function(el){ 174 | el.style.transform = "scale(1,0)"; 175 | }, 176 | end: function(el){ 177 | el.style.transform = ""; 178 | }, 179 | step: function(el,step,max){ 180 | var ratio = step / max; 181 | 182 | if(reverse){ 183 | ratio = 1 - ratio; 184 | } 185 | 186 | el.style.opacity = "0.0"; 187 | el.style.opacity = 1.0 - ratio; 188 | el.style.transformOrigin = "top"; 189 | el.style.transform = 190 | "scale(1,"+(1.0 - ratio)+")"; 191 | } 192 | } 193 | }; 194 | 195 | if(effects[effect] == undefined){ 196 | console.error("Animation '" + effect + "' does not exist."); 197 | return; 198 | } 199 | 200 | var effect = effects[effect]; 201 | 202 | /* reverse begin and end */ 203 | if(reverse == true){ 204 | var tmp = effect.begin; 205 | effect.begin = effect.end; 206 | effect.end = function(el){ 207 | // Hide before to prevent visual glitch 208 | hide(el); 209 | tmp(el); 210 | }; 211 | } 212 | 213 | animate(el, effect); 214 | } 215 | 216 | /* 217 | Make something appear "smoothly" 218 | */ 219 | function animated_remove(el, callback){ 220 | var options = { 221 | max: 10, 222 | begin: function(el){ 223 | el.style.position = "relative"; 224 | el.style.opacity = "1.0"; 225 | }, 226 | end: function(el){ 227 | el.style.position = ""; 228 | el.parentNode.removeChild(el); 229 | callback(); 230 | }, 231 | step: function(el,step,max){ 232 | var ratio = step / max; 233 | el.style.opacity = ratio; 234 | el.style.transform = "scale("+ratio+")"; 235 | el.style.top = 100 * (1.0 - ratio) + "px"; 236 | } 237 | }; 238 | animate(el,options); 239 | } 240 | 241 | /* 242 | Make something flash 243 | */ 244 | function flash(el,color,text_color){ 245 | var original_color, original_text; 246 | var options = { 247 | max: 2, 248 | time_step: 300, 249 | begin: function(el){ 250 | el.classList.add("flashing"); 251 | }, 252 | end: function(el){ 253 | el.classList.remove("flashing"); 254 | }, 255 | step: function(el,step,max){ 256 | } 257 | }; 258 | animate(el,options); 259 | } 260 | 261 | function animate(el,options,step){ 262 | max = options.max; 263 | time_step = options.time_step || 33; 264 | if(step == undefined){ 265 | step = max; 266 | options.begin(el); 267 | } 268 | if(step < 0){ 269 | options.end(el); 270 | return; 271 | } 272 | 273 | options.step(el, step, max); 274 | 275 | setTimeout( 276 | function(){ 277 | animate(el, options, step - 1); 278 | }, 279 | time_step 280 | ); 281 | } 282 | 283 | 284 | /** 285 | Interface between the shell and the server. 286 | */ 287 | function lc_network_engine(socket, shell){ 288 | var exports = {}; 289 | 290 | socket.on("too many users",function(sheet){ 291 | shell.die("Too many users are editing this sheet right now."+ 292 | " Try again later or create a new sheet."); 293 | }); 294 | 295 | socket.on("user data", function(data){ 296 | shell.on_user_data(data); 297 | }); 298 | 299 | socket.on("sheet",function(sheet){ 300 | shell.on_sheet(sheet); 301 | }); 302 | 303 | socket.on("user count",function(count){ 304 | shell.on_user_count(count); 305 | }); 306 | 307 | socket.on("definitive edit",function(data){ 308 | shell.on_edit_cell(data); 309 | }); 310 | 311 | socket.on("live edit",function(data){ 312 | shell.on_live_edit(data); 313 | }); 314 | 315 | socket.on("sheet locked",function(data){ 316 | shell.on_sheet_locked(data); 317 | }); 318 | 319 | socket.on("focus index",function(data){ 320 | shell.on_focus_index(data); 321 | }); 322 | 323 | socket.on("delete cell",function(data){ 324 | shell.on_delete_cell(data); 325 | }); 326 | 327 | exports.close = function(){ 328 | socket.close(); 329 | }; 330 | 331 | exports.send_nickname = function(nickname){ 332 | socket.emit("set nickname", { 333 | nickname: nickname 334 | }); 335 | }; 336 | 337 | exports.lock_sheet = function(){ 338 | socket.emit("lock sheet"); 339 | }; 340 | 341 | exports.delete_cell = function(index){ 342 | socket.emit("delete cell", { 343 | number: index 344 | }); 345 | }; 346 | 347 | exports.send_focus = function(index){ 348 | socket.emit("set focus", { 349 | index: index 350 | }); 351 | }; 352 | 353 | exports.edit_cell = function(index, value, method){ 354 | socket.emit("definitive edit", { 355 | number: index, 356 | content: value, 357 | method: method 358 | }); 359 | }; 360 | 361 | 362 | exports.live_edit_cell = function(index, value){ 363 | socket.emit("live edit", { 364 | number: index, 365 | content: value, 366 | }); 367 | }; 368 | 369 | exports.ask_user_id = function(){ 370 | socket.emit("give user id"); 371 | }; 372 | 373 | exports.send_user_id = function(id){ 374 | socket.emit("user id",{ 375 | user_id: id 376 | }); 377 | }; 378 | 379 | /* Less essential functionnality after this point */ 380 | 381 | // stats 382 | 383 | socket.on("sheet visit count", function(num){ 384 | shell.on_visit_count(num); 385 | }); 386 | 387 | return exports; 388 | } 389 | 390 | function mathjs_compute_engine(){ 391 | 392 | } 393 | 394 | function livecalc(root_el, namespace, user){ 395 | eeify_mathjs(); 396 | var chat; 397 | var scope = default_scope(); 398 | var current_focus = -1; 399 | 400 | function default_scope(){ 401 | return { 402 | ans: undefined 403 | }; 404 | } 405 | 406 | // Create template 407 | render("livecalc", root_el); 408 | 409 | var cells = subqsa(root_el,".livecalc-cells")[0]; 410 | var cell_count; 411 | var exports = {}; 412 | var currently_calculated_cell = null 413 | var socket = io("/" + namespace); 414 | var params = {}; 415 | 416 | var net_engine = lc_network_engine(socket, exports); 417 | 418 | exports.el = root_el; 419 | 420 | exports.socket = socket; 421 | 422 | new_cell("", true, true); 423 | 424 | exports.on_sheet = function(sheet){ 425 | load_json(sheet); 426 | }; 427 | 428 | var user_count = subqsa(root_el, ".user-count")[0]; 429 | 430 | if(user.has_id() == false){ 431 | net_engine.ask_user_id(); 432 | } else { 433 | net_engine.send_user_id(user.get_public_id()); 434 | } 435 | 436 | { 437 | // share url activation 438 | var url_share_button = qsa(".url-popup-modal")[0]; 439 | 440 | url_share_button.onclick = function(e){ 441 | e.preventDefault(); 442 | var link = url_share_button.href; 443 | modal_inform(link); 444 | } 445 | } 446 | 447 | exports.die = function(message){ 448 | render("livecalc-die",root_el); 449 | 450 | if(chat != undefined){ 451 | chat.die(); 452 | } 453 | 454 | var buttons = [ 455 | { 456 | text: "Create a new sheet", 457 | action: function(modal){ 458 | window.location.href = "/new"; 459 | modal.close(); 460 | } 461 | }, 462 | { 463 | text: "Go back to homepage", 464 | action: function(modal){ 465 | window.location.href = "/"; 466 | modal.close(); 467 | } 468 | } 469 | ]; 470 | 471 | modal(message, buttons); 472 | }; 473 | 474 | exports.set_chat = function(c){ 475 | chat = c; 476 | }; 477 | 478 | exports.on_user_count = function(count){ 479 | var plural = count > 1; 480 | var count = parseInt(count); 481 | user_count.innerHTML = count + " user" + (plural? "s": ""); 482 | }; 483 | 484 | exports.on_edit_cell = function(data){ 485 | var number = data.number; 486 | var content = data.content; 487 | var method = data.method; 488 | edit_cell(number, content, method); 489 | }; 490 | 491 | exports.on_sheet_locked = function(data){ 492 | // data.initiator is normally sanited on server 493 | modal_inform( "Sheet was locked by \"" + 494 | data.initiator + 495 | "\". You can still open a copy." 496 | ); 497 | 498 | params.locked = true; 499 | 500 | update_state(); 501 | }; 502 | 503 | exports.on_focus_index = function(data){ 504 | for(var i = 0; i < data.length; i++){ 505 | var cell = find_cell(i); 506 | 507 | if(cell == null){ 508 | return; 509 | } 510 | 511 | var usersinfo = cell.usersinfo; 512 | 513 | if(Array.isArray(data[i]) && data[i].length > 0){ 514 | usersinfo.textContent = data[i].join(", ") + " editing this cell..."; 515 | } else { 516 | usersinfo.textContent = ""; 517 | } 518 | } 519 | }; 520 | 521 | exports.on_delete_cell = function(data){ 522 | var number = data.number; 523 | delete_cell(number, true); 524 | } 525 | 526 | window.addEventListener("beforeunload", net_engine.close); 527 | 528 | var nickname = ""; 529 | 530 | function init_user_data(){ 531 | var nickname_input = subqsa(root_el, ".nickname input")[0]; 532 | var nickname_button = subqsa(root_el, ".nickname button")[0]; 533 | var username_field = qsa(".user-name")[0]; 534 | 535 | exports.set_nickname = function(new_nickname){ 536 | nickname_input.value = new_nickname; 537 | nickname = new_nickname; 538 | username_field.innerText = nickname; 539 | }; 540 | 541 | exports.on_user_data = function(data){ 542 | user.set_public_id(data.public_id); 543 | exports.set_nickname(data.nickname); 544 | chat.on_user_ready(); 545 | }; 546 | 547 | exports.send_nickname = function(){ 548 | net_engine.send_nickname(nickname); 549 | } 550 | 551 | nickname_input.onkeydown = function(e){ 552 | if(e.keyCode == 13){ 553 | submit(); 554 | } 555 | } 556 | 557 | nickname_button.addEventListener("click",function(){ 558 | submit(); 559 | }); 560 | 561 | function submit(){ 562 | nickname = nickname_input.value; 563 | flash(nickname_input,"#eee","#333"); 564 | exports.send_nickname(); 565 | } 566 | } 567 | 568 | init_user_data(); 569 | 570 | function init_sheet_panel(){ 571 | var panel = qsa(".sheet-panel")[0]; 572 | 573 | var lock_sheet_button = subqsa( 574 | root_el, 575 | "button[name='lock-sheet']" 576 | )[0]; 577 | 578 | lock_sheet_button.onclick = function(){ 579 | if(namespace == "demo"){ 580 | modal_inform("This sheet is the public demo, it can't be locked."); 581 | return; 582 | } 583 | 584 | modal_yesno( 585 | "This action cannot be undone. " + 586 | "Nobody will be able to modify this " + 587 | "sheet after you click \"yes\". " + 588 | "Do you really want to lock the sheet?", 589 | function(yes){ 590 | if(yes){ 591 | net_engine.lock_sheet(); 592 | } 593 | }); 594 | }; 595 | 596 | var new_copy_button = subqsa( 597 | root_el, 598 | "button[name='new-copy']" 599 | )[0]; 600 | 601 | new_copy_button.onclick = function(){ 602 | window.location.href = "/copy/"+namespace; 603 | } 604 | 605 | var visit_count = subqsa(panel, ".visit-count")[0]; 606 | 607 | exports.on_visit_count = function(count){ 608 | visit_count.textContent = count; 609 | }; 610 | } 611 | 612 | init_sheet_panel(); 613 | 614 | /* 615 | Delete a cell. If remote, we don't send an event to the server. 616 | */ 617 | function delete_cell(index, remote){ 618 | var remote = remote || false; 619 | 620 | // Never delete last remaining cell 621 | var len = cells.children.length; 622 | 623 | if((index > 0 || len > 1) && index < len){ 624 | var cell = find_cell(index).element; 625 | 626 | // Avoid deleting more than one time 627 | if(cell.getAttribute("data-deleting") == "true"){ 628 | return; 629 | } 630 | 631 | cell.setAttribute("data-deleting","true"); 632 | 633 | animated_remove(cell,function(){ 634 | update_indices(); 635 | focus(index-1); 636 | }); 637 | 638 | if(!remote){ 639 | net_engine.delete_cell(index); 640 | } 641 | } 642 | } 643 | 644 | function delete_all(){ 645 | cells.innerHTML = ""; 646 | } 647 | 648 | function re_run(){ 649 | scope = default_scope(); 650 | for(var i = 0; i < cells.children.length; i++){ 651 | cells.children[i].calculate(); 652 | } 653 | } 654 | 655 | exports.re_run = re_run; 656 | 657 | function load_json(data){ 658 | var data = JSON.parse(data); 659 | var cells = data.cells; 660 | params = data.params; 661 | 662 | update_state(); 663 | 664 | delete_all(); 665 | 666 | for(var i = 0; i < cells.length; i++){ 667 | new_cell(cells[i], true, false); 668 | } 669 | re_run(); 670 | focus(0); 671 | } 672 | 673 | exports.load_json = load_json; 674 | 675 | var sheet_state = subqsa(root_el, ".sheet-state")[0]; 676 | 677 | function update_state(){ 678 | if(params.locked){ 679 | sheet_state.textContent = "This sheet is locked."; 680 | sheet_state.setAttribute("title","Modifications will not be saved. You can still open a copy (See bottom of the page)."); 681 | } else { 682 | sheet_state.textContent = "This sheet is public."; 683 | sheet_state.setAttribute("title",""); 684 | } 685 | } 686 | 687 | update_state(); 688 | 689 | function send_all(){ 690 | for(var i = 0; i < cells.children.length; i++){ 691 | send_value(i); 692 | } 693 | } 694 | 695 | exports.send_all = send_all; 696 | 697 | function focus(index){ 698 | if(index >= cell_count || index < 0){ 699 | return; 700 | } 701 | 702 | find_cell(index).input.focus(); 703 | send_focus(index); 704 | } 705 | 706 | exports.focus = focus; 707 | 708 | function send_focus(index){ 709 | if(index == null){ 710 | index = -1; 711 | } 712 | current_focus = index; 713 | net_engine.send_focus(index); 714 | } 715 | 716 | function find_cell(index){ 717 | var el = cells.children[index]; 718 | 719 | if(el == undefined){ 720 | return null; 721 | } 722 | 723 | return { 724 | element: el, 725 | input: subqsa(el, ".livecalc-input")[0], 726 | button: subqsa(el, ".livecalc-go-button")[0], 727 | output: subqsa(el,".livecalc-output")[0], 728 | secondary_output: subqsa(el,".livecalc-secondary-output")[0], 729 | usersinfo: subqsa(el,".users-info")[0], 730 | text_part: subqsa(el,".text-part")[0], 731 | math_part: subqsa(el,".math-part")[0], 732 | plot: subqsa(el,".plot")[0] 733 | }; 734 | } 735 | 736 | exports.find_cell = find_cell; 737 | 738 | function update_indices(){ 739 | var i = 0; 740 | for(i = 0; i < cells.children.length; i++){ 741 | var cell = cells.children[i]; 742 | cell.setAttribute("data-index", i); 743 | subqsa(cell,".livecalc-input")[0] 744 | .setAttribute("tabindex", i + 1); 745 | } 746 | cell_count = i; 747 | } 748 | 749 | function edit_cell(number, content, method){ 750 | grow_to(number); 751 | 752 | if(method == "insert"){ 753 | new_cell(content, false, false, number); 754 | } else { 755 | var field = find_cell(number).input; 756 | field.value = content; 757 | } 758 | 759 | calculate_cell(number); 760 | } 761 | 762 | /* To add cells if required*/ 763 | function grow_to(number){ 764 | var from = cells.children.length; 765 | var to = number; 766 | 767 | for(i = from; i <= to; i++){ 768 | new_cell("", false, false); 769 | } 770 | } 771 | 772 | function insert_cell_at(index, send_data, callback){ 773 | new_cell("", send_data, true, index); 774 | callback(); 775 | } 776 | 777 | function new_cell(content, send_data, animate, at_index){ 778 | var exports = {}; 779 | var content = content || ""; 780 | var method = "append"; 781 | 782 | if(at_index == undefined || at_index < 0){ 783 | at_index = -1; 784 | } else { 785 | method = "insert"; 786 | } 787 | 788 | cell_count++; 789 | var cell = dom(load_template("livecalc-cell").content); 790 | 791 | if(at_index == -1){ 792 | // Append at end 793 | cells.appendChild(cell); 794 | } else { 795 | // Append at index 796 | cells.insertBefore( 797 | cell, // Insert this 798 | find_cell(at_index).element // Before this cell 799 | ); 800 | } 801 | 802 | // This should not be used anymore, 803 | // since it may change anytime. 804 | // use get_index() 805 | at_index = undefined; 806 | 807 | update_indices(); 808 | 809 | var cell_data = find_cell(get_index()); 810 | var input = cell_data.input; 811 | var button = cell_data.button; 812 | var output = cell_data.output; 813 | var text_part = cell_data.text_part; 814 | var math_part = cell_data.math_part; 815 | var secondary_output = cell_data.secondary_output; 816 | 817 | if(animate == true){ 818 | appear(cell); 819 | } 820 | 821 | var add_cell_button = subqsa(cell, ".add-cell-button .inner")[0]; 822 | 823 | add_cell_button.onclick = function(){ 824 | var index = get_index(); 825 | insert_cell_at(index, true,function(){ 826 | focus(index); 827 | }); 828 | } 829 | 830 | input.setAttribute("value", content); 831 | 832 | /* Make sure inputs are shown on mouse click */ 833 | text_part.addEventListener("click",function(){ 834 | if(hidden(math_part)){ 835 | // Show math part for edition 836 | show(math_part); 837 | appear(math_part,"from top"); 838 | input.focus(); 839 | } else { 840 | // Hide 841 | hide(math_part); 842 | } 843 | }); 844 | 845 | // Hide these at beginning 846 | hide(text_part); 847 | hide(secondary_output); 848 | 849 | function get_index(){ 850 | return parseInt(cell.getAttribute("data-index")); 851 | } 852 | 853 | exports.get_index = get_index; 854 | 855 | function get_value(){ 856 | return input.value; 857 | } 858 | 859 | function calculate(){ 860 | calculate_cell(get_index()); 861 | }; 862 | 863 | input.addEventListener("click",function(){ 864 | send_focus(get_index()); 865 | }); 866 | 867 | /* lost focus */ 868 | input.addEventListener("blur",function(){ 869 | send_focus(-1); 870 | }); 871 | 872 | cell.calculate = calculate; 873 | 874 | { 875 | // manage delete cell button 876 | var delete_cell_button 877 | = subqsa(cell,".delete-cell-button")[0]; 878 | 879 | delete_cell_button.onclick = function(){ 880 | delete_cell(get_index()); 881 | }; 882 | } 883 | 884 | var operation_keys = ["+","-","*","/"]; 885 | 886 | input.onkeydown = function(e){ 887 | var key_num = e.keyCode || e.which; 888 | var has_live_edit = true; 889 | 890 | if(e.keyCode == 13 && !e.shiftKey){ 891 | // Enter key 892 | e.preventDefault(); 893 | if(get_value() != ""){ 894 | send_value(get_index()); 895 | go(); 896 | 897 | // Edit will already be send 898 | has_live_edit = false; 899 | } 900 | } else if (e.keyCode == 38) { 901 | // Up arrow 902 | focus(get_index()-1); 903 | } else if (e.keyCode == 40) { 904 | // Down arrow 905 | focus(get_index()+1); 906 | } else if(e.code == "Backspace"){ 907 | // Delete cell 908 | if(get_value() == ""){ 909 | delete_cell(get_index()); 910 | 911 | // delete_cell fires a delete event 912 | // sending edit would be a problem 913 | has_live_edit = false; 914 | } 915 | } else { 916 | // Detect if an operator 917 | // was inserted (+ - * /) 918 | // and wrap selected text 919 | for(var i in operation_keys){ 920 | var op = operation_keys[i]; 921 | if(e.key == op){ 922 | operation_keydown(e, op); 923 | } 924 | } 925 | } 926 | 927 | if(has_live_edit){ 928 | send_live_throttled(); 929 | } 930 | 931 | var last_live_edit_send = time(); 932 | var time_threshold = 350; 933 | var has_waiting_timeout = false; 934 | 935 | /* Send data, but not too often */ 936 | function send_live_throttled(){ 937 | if(time() - last_live_edit_send > time_threshold){ 938 | send_live_edit(get_index()); 939 | } else { 940 | if(!has_waiting_timeout){ 941 | setTimeout(function(){ 942 | send_live_throttled(); 943 | has_waiting_timeout = false; 944 | }, 945 | time() - time_threshold 946 | ); 947 | has_waiting_timeout = true; 948 | } 949 | } 950 | } 951 | 952 | function time(){ 953 | return (new Date()).getTime(); 954 | } 955 | } 956 | 957 | /* 958 | 959 | Manage text selection smartly 960 | when user types an operator + / - / * / '/' 961 | 962 | And move cursor to a practical position. 963 | 964 | */ 965 | function operation_keydown(e, operation){ 966 | var start = input.selectionStart; 967 | var end = input.selectionEnd; 968 | 969 | if(start != end){ 970 | e.preventDefault(); 971 | 972 | // By default, place cursor after operator 973 | var inside = false; 974 | 975 | if( operation == "+" || 976 | operation == "-" 977 | ){ 978 | inside = true; 979 | } 980 | 981 | var after = ")" + operation + "[[cursor]]"; 982 | 983 | if(inside){ 984 | after = operation + "[[cursor]]" + ")"; 985 | } 986 | 987 | selection_wrap(input, "(", after, operation); 988 | } 989 | } 990 | 991 | function go(){ 992 | calculate(); 993 | 994 | var index = get_index(); 995 | 996 | // If last cell, add new cell 997 | if(index == cell_count - 1){ 998 | new_cell("", true, true); 999 | } 1000 | } 1001 | 1002 | button.onclick = function(){ 1003 | send_value(get_index()); 1004 | go(); 1005 | }; 1006 | 1007 | exports.calculate = calculate; 1008 | 1009 | input.focus(); 1010 | 1011 | if(send_data){ 1012 | send_focus(get_index()); 1013 | send_value(get_index(), method); 1014 | } 1015 | 1016 | return exports; 1017 | } 1018 | 1019 | /* 1020 | before_sel : something to place before selection 1021 | after_sel : idem, after 1022 | 1023 | puts selections at [[cursor]] ans [[cursor-end]] 1024 | */ 1025 | function selection_wrap(input, before_sel, after_sel, fallback, no_selection){ 1026 | var fallback = fallback || ""; 1027 | 1028 | var start = input.selectionStart; 1029 | var end = input.selectionEnd; 1030 | 1031 | // if the browser does not support this feature, 1032 | // just add fallback at input end 1033 | if(input.selectionStart == undefined){ 1034 | input.value += fallback; 1035 | return; 1036 | } 1037 | 1038 | // If there is no selection 1039 | // add the appropriate value (no_selection) if present 1040 | // or the fallback 1041 | if(start == end){ 1042 | before_sel = ""; 1043 | after_sel = no_selection || (fallback + "[[cursor]]"); 1044 | } 1045 | 1046 | var val = input.value; 1047 | var before = val.substr(0,start); 1048 | var between = val.substr(start, end - start); 1049 | var after = val.substr(end,val.length - end); 1050 | 1051 | var new_val = before; 1052 | new_val += before_sel; 1053 | new_val += between; 1054 | new_val += after_sel; 1055 | new_val += after; 1056 | 1057 | var new_start = new_val.indexOf("[[cursor]]"); 1058 | new_val = new_val.replace("[[cursor]]",""); 1059 | 1060 | var new_end = new_val.indexOf("[[cursor-end]]"); 1061 | 1062 | // Actually using [[cursor-end]]? 1063 | if(new_end != -1){ 1064 | // Yes 1065 | new_val = new_val.replace("[[cursor-end]]",""); 1066 | } else { 1067 | // Nope, just place cursor 1068 | new_end = new_start; 1069 | } 1070 | 1071 | input.value = new_val; 1072 | 1073 | input.selectionStart = new_start; 1074 | input.selectionEnd = new_end; 1075 | } 1076 | 1077 | exports.new_cell = new_cell; 1078 | 1079 | function send_live_edit(index){ 1080 | var cell_data = find_cell(index); 1081 | var input = cell_data.input; 1082 | net_engine.live_edit_cell(index, input.value); 1083 | } 1084 | 1085 | exports.on_live_edit = on_live_edit; 1086 | 1087 | function on_live_edit(data){ 1088 | var cell_data = find_cell(data.number); 1089 | var input = cell_data.input; 1090 | input.value = data.content; 1091 | } 1092 | 1093 | function send_value(index, method){ 1094 | var method = method || "append"; 1095 | 1096 | var cell_data = find_cell(index); 1097 | 1098 | var input = cell_data.input; 1099 | 1100 | net_engine.edit_cell(index, input.value, method); 1101 | } 1102 | 1103 | function calculate_cell(index){ 1104 | var cell_data = find_cell(index); 1105 | currently_calculated_cell = cell_data; 1106 | var cell = cell_data.element; 1107 | var text_part = cell_data.text_part; 1108 | var math_part = cell_data.math_part; 1109 | var plot_el = cell_data.plot; 1110 | var input = cell_data.input; 1111 | var output = cell_data.output; 1112 | var secondary_output = cell_data.secondary_output; 1113 | var value = input.value; 1114 | var math_value = value; 1115 | var text_comment = ""; 1116 | 1117 | plot_el.innerHTML = ""; 1118 | show(math_part); 1119 | hide(secondary_output); 1120 | 1121 | // Extract comment 1122 | var comment_pos = value.indexOf("//"); 1123 | 1124 | if(comment_pos != -1){ 1125 | text_comment = value.substr(comment_pos+2,value.length); 1126 | math_value = value.substr(0,comment_pos); 1127 | } 1128 | 1129 | if(text_comment != "" && math_value == ""){ 1130 | // Has comment but no math 1131 | // Show only text part 1132 | text_part.textContent = text_comment; 1133 | show(text_part); 1134 | hide(math_part); 1135 | return; 1136 | } else if (math_value != "" && text_comment == ""){ 1137 | // Has math but no comment 1138 | // Show only math part 1139 | text_part.textContent = ""; 1140 | hide(text_part); 1141 | show(math_part); 1142 | } else if (math_value != "" && text_comment != ""){ 1143 | // Has math and comment 1144 | // Show math (includes comment anyway) 1145 | show(math_part); 1146 | hide(text_part); 1147 | } else { 1148 | // No value, no comment 1149 | // Show only math 1150 | show(math_part); 1151 | hide(text_part); 1152 | return; 1153 | } 1154 | 1155 | var text = ee_parse(math_value); 1156 | 1157 | // Evaluate and display errors/result 1158 | try{ 1159 | var result = math.eval(text, scope); 1160 | scope["ans"] = result; 1161 | } catch (exception){ 1162 | output.textContent = exception; 1163 | return; 1164 | } 1165 | 1166 | secondary_output.innerHTML = ""; 1167 | 1168 | if(text == ""){ 1169 | return; 1170 | } else if(result != undefined){ 1171 | if(typeof result == "function"){ 1172 | output.textContent = "[function]"; 1173 | } else if (typeof result == "number"){ 1174 | // Here, we will round values if needed 1175 | var rounded = parseFloat(result.toPrecision(10)); 1176 | var final_output = ""; 1177 | 1178 | // If we display a rounding, inform the user 1179 | // and also show the non-rounded value. 1180 | if(result != rounded){ 1181 | final_output = rounded; 1182 | secondary_output.textContent = 1183 | "Raw float value: " + 1184 | result; 1185 | show(secondary_output); 1186 | } else { 1187 | final_output = result; 1188 | } 1189 | 1190 | output.textContent = final_output; 1191 | } else { 1192 | output.textContent = result; 1193 | } 1194 | } else { 1195 | output.textContent = "[undefined]"; 1196 | return; 1197 | } 1198 | 1199 | flash(output,"#09bc8a","#ffffff"); 1200 | } 1201 | 1202 | /* 1203 | Add some useful stuff to math.js 1204 | */ 1205 | function eeify_mathjs(){ 1206 | math.import({ 1207 | /* Parallel resistors */ 1208 | LL: function(a,b){ 1209 | var num = 0; 1210 | for(i in arguments){ 1211 | var arg = arguments[i]; 1212 | num += 1/arg; 1213 | } 1214 | return 1 / num; 1215 | }, 1216 | rule: function(number){ 1217 | var cell = currently_calculated_cell; 1218 | wait_for_click(cell, function(){ 1219 | rule(cell.plot, number) 1220 | }); 1221 | return ""; 1222 | }, 1223 | plot: plot, 1224 | zfractal: function(e,i,s){ 1225 | var cell = currently_calculated_cell; 1226 | wait_for_click(cell, function(){ 1227 | zfractal(cell.plot, e, i, s); 1228 | }); 1229 | return ""; 1230 | }, 1231 | "π": math.pi 1232 | }); 1233 | } 1234 | 1235 | /* 1236 | Wait for user click before 1237 | calculating something potentially long 1238 | */ 1239 | function wait_for_click(cell, callback){ 1240 | render("livecalc-wait-click", cell.plot); 1241 | 1242 | var button = subqsa(cell.plot,"button")[0]; 1243 | button.onclick = go; 1244 | 1245 | function go(){ 1246 | button.innerHTML = "Computing..."; 1247 | setTimeout(callback,100); 1248 | } 1249 | } 1250 | 1251 | /* 1252 | Cellular automata 1253 | */ 1254 | function rule(plot_el, number){ 1255 | var number = parseInt(number); 1256 | 1257 | plot_el.innerHTML = ""; 1258 | 1259 | if(number < 0 || number > 255){ 1260 | throw "Number should be between 0 and 255"; 1261 | } 1262 | 1263 | var div_width = plot_el.clientWidth; 1264 | 1265 | var grid_size = 100; 1266 | var pixel_size = 4; 1267 | 1268 | var width = grid_size; 1269 | var height = grid_size; 1270 | 1271 | var can = dom(""); 1272 | var ctx = can.getContext("2d"); 1273 | 1274 | plot_el.appendChild(can); 1275 | 1276 | can.width = width * pixel_size; 1277 | can.height = height * pixel_size; 1278 | 1279 | var imgdata = ctx.createImageData(can.width, can.height); 1280 | 1281 | var line = new_line(); 1282 | 1283 | line[parseInt(width/2)] = true; 1284 | 1285 | // This is a port of my GLSL code found 1286 | // [here](https://github.com/antoineMoPa/ogl-js/blob/master/tests/triangles/post-fragment.glsl) 1287 | 1288 | // Draw a pixel 1289 | // (That may be many pixel, depending on pixel_size) 1290 | function set_pixel(i,j,val){ 1291 | var j = j * pixel_size; 1292 | var i = i * pixel_size; 1293 | 1294 | // repeat the pixel 1295 | for(var k = 0; k < pixel_size; k++){ 1296 | for(var l = 0; l < pixel_size; l++){ 1297 | set_one_pixel(i+k,j+l,val); 1298 | } 1299 | } 1300 | } 1301 | 1302 | // Draw one actual canvas pixel 1303 | function set_one_pixel(i,j,val){ 1304 | var index = 4 * (j * can.width + i); 1305 | // Set value 1306 | imgdata.data[index + 0] = val; 1307 | imgdata.data[index + 1] = val; 1308 | imgdata.data[index + 2] = val; 1309 | imgdata.data[index + 3] = 255; 1310 | } 1311 | 1312 | // Parse screen and follow the rule to create new line 1313 | for(var j = 0; j < height; j++){ 1314 | // Keep last line in memory 1315 | var last_line = copy_line(line); 1316 | for(var i = 0; i < width; i++){ 1317 | // Drawing 1318 | var val = 230; 1319 | 1320 | if(line[i]){ 1321 | val = 30; 1322 | } 1323 | 1324 | set_pixel(i, j, val); 1325 | 1326 | var cell_1 = last_line[i-1]; 1327 | var cell_2 = last_line[i]; 1328 | var cell_3 = last_line[i+1]; 1329 | 1330 | var num = 0; 1331 | 1332 | if(cell_1){ 1333 | num += 4; 1334 | } 1335 | if(cell_2){ 1336 | num += 2; 1337 | } 1338 | if(cell_3){ 1339 | num += 1; 1340 | } 1341 | 1342 | next = false; 1343 | 1344 | // `rule`: 8 bits 1345 | // `num`: 3 bits addressing `rule` bits 1346 | // 1347 | // `rule` indicates which cases of `num` will produce 1348 | // an open pixel 1349 | // 1350 | // bitwise or (&) operator example: 1351 | // 0010 0000 & 0010 0001 == 0010 0000 1352 | // 1353 | // Example with rule 3 1354 | // Rule 3 = 0000 0011 1355 | // So bits 1 and 2 are activated 1356 | // Which means 2^0 and 2^1 is activated 1357 | // 0000 0011 & 0000 0001 != 0 and 1358 | // 0000 0011 & 0000 0010 != 0 1359 | // 1360 | // In these cases, the next state of the pixel is `1` 1361 | // 1362 | if(number & parseInt(Math.pow(2,num))){ 1363 | next = true; 1364 | } 1365 | 1366 | if(next){ 1367 | line[i] = true; 1368 | } else { 1369 | line[i] = false; 1370 | } 1371 | } 1372 | } 1373 | 1374 | function new_line(){ 1375 | var line = []; 1376 | for(var i = 0; i < width; i++){ 1377 | line.push(false); 1378 | } 1379 | return line; 1380 | } 1381 | 1382 | function copy_line(old){ 1383 | var line = []; 1384 | for(var i = 0; i < old.length; i++){ 1385 | line.push(old[i]); 1386 | } 1387 | return line; 1388 | } 1389 | 1390 | ctx.putImageData(imgdata,0,0); 1391 | 1392 | // We must return a value 1393 | return ""; 1394 | } 1395 | 1396 | function plot(){ 1397 | var plot_el = currently_calculated_cell.plot; 1398 | var fullscreen_button = render("plot-interact-button"); 1399 | var div_width = plot_el.clientWidth; 1400 | 1401 | var functions_data = []; 1402 | 1403 | for(i in arguments){ 1404 | var expression = arguments[i]; 1405 | functions_data.push({ 1406 | sampler: 'builtIn', /* To use math.js */ 1407 | graphType: 'polyline', /* To use math.js */ 1408 | fn: expression 1409 | }); 1410 | } 1411 | 1412 | 1413 | // For most screens: keep this width 1414 | // To optimize vertical space used + 1415 | // pragmatic aspect ratio 1416 | var width = 550; 1417 | 1418 | // Smaller screens limit widthx 1419 | if(div_width < 550){ 1420 | width = div_width - 10; 1421 | } 1422 | 1423 | functionPlot({ 1424 | target: plot_el, 1425 | width: width, 1426 | disableZoom: true, 1427 | data: functions_data, 1428 | grid: true 1429 | }); 1430 | 1431 | plot_el.appendChild(fullscreen_button); 1432 | 1433 | fullscreen_button.onclick = function(){ 1434 | fullscreen(expression, function(content){ 1435 | functionPlot({ 1436 | target: content, 1437 | width: window.innerWidth, 1438 | height: window.innerHeight - 100, 1439 | disableZoom: false, 1440 | data: functions_data, 1441 | grid: true 1442 | }); 1443 | }); 1444 | 1445 | /* 1446 | callback(content_dom_element) 1447 | */ 1448 | function fullscreen(title_text, callback){ 1449 | var fullscreen_el = render("fullscreen"); 1450 | var close_button = subqsa(fullscreen_el, ".close-button")[0]; 1451 | var content = subqsa(fullscreen_el, ".content")[0]; 1452 | var title = subqsa(fullscreen_el, ".fullscreen-title")[0]; 1453 | 1454 | title.textContent = title_text; 1455 | 1456 | close_button.onclick = function(){ 1457 | fullscreen_el.parentNode.removeChild(fullscreen_el); 1458 | }; 1459 | 1460 | document.body.appendChild(fullscreen_el); 1461 | callback(content); 1462 | } 1463 | } 1464 | 1465 | // We must return a value 1466 | return ""; 1467 | } 1468 | 1469 | function zfractal(plot_el, expression, iterations, size){ 1470 | var iterations = iterations || 10; 1471 | var exp = math.compile(expression); 1472 | 1473 | plot_el.innerHTML = ""; 1474 | 1475 | var div_width = plot_el.clientWidth; 1476 | var pixel_ratio = 1; 1477 | 1478 | var size = size || 30; 1479 | 1480 | var width = size; 1481 | var height = size; 1482 | 1483 | var can = dom(""); 1484 | var ctx = can.getContext("2d"); 1485 | 1486 | plot_el.appendChild(can); 1487 | 1488 | // Make it square 1489 | can.width = width * pixel_ratio; 1490 | can.height = height * pixel_ratio; 1491 | 1492 | var data = ctx.createImageData(width, height); 1493 | 1494 | for(var i = 0; i < width; i++){ 1495 | for(var j = 0; j < height; j++){ 1496 | scope.c = math.complex( 1497 | 4.0 * (i/width - 0.5), 1498 | 4.0 * (j/height - 0.5) 1499 | ); 1500 | 1501 | scope.z = math.complex(scope.c); 1502 | 1503 | var val = 255; 1504 | 1505 | for(var k = 0; k < iterations; k++){ 1506 | scope.z = exp.eval(scope); 1507 | if(len(scope.z) > 2.0){ 1508 | val = parseInt(((k/iterations) * 255)); 1509 | break; 1510 | } 1511 | } 1512 | 1513 | var index = 4 * (j * width + i); 1514 | 1515 | data.data[index + 0] = val; 1516 | data.data[index + 1] = val; 1517 | data.data[index + 2] = val; 1518 | data.data[index + 3] = 255 1519 | } 1520 | } 1521 | 1522 | ctx.putImageData(data,0,0); 1523 | 1524 | function len(z){ 1525 | return Math.sqrt( 1526 | Math.pow(z.re,2) + 1527 | Math.pow(z.im,2) 1528 | ); 1529 | } 1530 | } 1531 | 1532 | var palette_el = qsa(".palette")[0]; 1533 | 1534 | exports.palette = palette_el; 1535 | 1536 | if(palette_el){ 1537 | init_palette(palette_el); 1538 | } 1539 | 1540 | function init_palette(palette){ 1541 | palette_el.addEventListener("mousedown",function(event){ 1542 | // Prevent focus loss to input 1543 | event.preventDefault(); 1544 | event.stopPropagation(); 1545 | 1546 | var el = event.target; 1547 | 1548 | // Is it a button ? 1549 | if(el.tagName.toLowerCase() == "button"){ 1550 | var cell = find_cell(current_focus); 1551 | 1552 | if(cell != null){ 1553 | var input = cell.input; 1554 | on_click(el, input); 1555 | } else if (chat.has_focus){ 1556 | on_click(el, chat.textarea); 1557 | } 1558 | } 1559 | }); 1560 | 1561 | function on_click(button, input){ 1562 | var value = button.innerText; 1563 | 1564 | // Are we using selection_wrap? 1565 | if(button.hasAttribute("data-wrap-before")){ 1566 | var before = button.getAttribute("data-wrap-before"); 1567 | var after = ""; 1568 | var fallback = button.innerText 1569 | var no_selection = fallback + "[[cursor]]"; 1570 | 1571 | if(button.hasAttribute("data-wrap-after")){ 1572 | after = button.getAttribute("data-wrap-after") 1573 | } 1574 | 1575 | if(button.hasAttribute("data-no-sel")){ 1576 | no_selection = button.getAttribute("data-no-sel") 1577 | } 1578 | 1579 | selection_wrap(input, before, after, fallback, no_selection); 1580 | 1581 | return; 1582 | } 1583 | 1584 | if(input.selectionStart != undefined){ 1585 | var offset = input.selectionStart; 1586 | 1587 | var curr_val = input.value; // Current input value 1588 | var input_begin = curr_val.substr(0,offset); 1589 | var input_end = curr_val.substr(offset,curr_val.length); 1590 | 1591 | if(offset != input.selectionEnd){ 1592 | // If some text is selected, we'll replace it 1593 | // So erase that part 1594 | 1595 | input_end = curr_val.substr( 1596 | input.selectionEnd, 1597 | curr_val.length 1598 | ); 1599 | } 1600 | 1601 | input.value = 1602 | input_begin + 1603 | value + 1604 | input_end; 1605 | 1606 | // Remove variable and place 1607 | // user cursor 1608 | var replace_var = 1609 | button.getAttribute("data-replace-var") || ""; 1610 | 1611 | if(replace_var != ""){ 1612 | var var_pos = value.indexOf(replace_var); 1613 | var var_len = replace_var.length; 1614 | 1615 | // Select variable 1616 | input.selectionStart = 1617 | input_begin.length + var_pos; 1618 | input.selectionEnd = 1619 | input_begin.length + var_pos + var_len; 1620 | } else { 1621 | // Place back mouse after what we inserted 1622 | var new_offset = input_begin.length + 1623 | value.length; 1624 | 1625 | input.selectionStart = new_offset; 1626 | input.selectionEnd = new_offset; 1627 | } 1628 | } else { 1629 | input.value += value; 1630 | } 1631 | } 1632 | } 1633 | 1634 | return exports; 1635 | } 1636 | 1637 | // Replace electrical engineering notation 1638 | function ee_parse(str){ 1639 | str = str.replace(/([0-9]+)G/g, "$1E9"); 1640 | str = str.replace(/([0-9]+)M/g, "$1E6"); 1641 | str = str.replace(/([0-9]+)meg/g,"$1E6"); 1642 | str = str.replace(/([0-9]+)K/g, "$1E3"); 1643 | str = str.replace(/([0-9]+)k/g, "$1E3"); 1644 | str = str.replace(/([0-9]+)m/g, "$1E-3"); 1645 | str = str.replace(/([0-9]+)u/g, "$1E-6"); 1646 | str = str.replace(/([0-9]+)n/g, "$1E-9"); 1647 | str = str.replace(/([0-9]+)p/g, "$1E-12"); 1648 | str = str.replace(/\*\*/g, "^"); 1649 | return str; 1650 | } 1651 | 1652 | function init_doc(calc){ 1653 | var codes = subqsa(calc.el, ".doc code"); 1654 | 1655 | for(var i = 0; i < codes.length; i++){ 1656 | var el = codes[i]; 1657 | var content = el.innerHTML; 1658 | 1659 | init_click(el, content); 1660 | el.setAttribute("title","Click to add to sheet"); 1661 | } 1662 | 1663 | function init_click(el, code){ 1664 | el.onclick = function(){ 1665 | var cell = calc.new_cell(code, true, true); 1666 | cell.calculate(); 1667 | var dom_data = calc.find_cell(cell.get_index()); 1668 | show(dom_data.math_part); 1669 | }; 1670 | } 1671 | } 1672 | 1673 | function livechat(root_el, namespace, socket, user){ 1674 | render("livechat", root_el); 1675 | 1676 | var log = subqsa(root_el, ".message-log")[0]; 1677 | var textarea = subqsa(root_el, "textarea")[0]; 1678 | var button = subqsa(root_el, "button")[0]; 1679 | var exports = {}; 1680 | 1681 | textarea.value = ""; 1682 | 1683 | exports.root_el = root_el; 1684 | 1685 | exports.has_focus = false; 1686 | exports.textarea = textarea; 1687 | 1688 | textarea.addEventListener("focus",function(){ 1689 | exports.has_focus = true; 1690 | }); 1691 | 1692 | textarea.addEventListener("blur",function(){ 1693 | exports.has_focus = false; 1694 | }); 1695 | 1696 | exports.die = function(){ 1697 | root_el.innerHTML = ""; 1698 | }; 1699 | 1700 | var past_messages_loaded = false; 1701 | 1702 | exports.on_user_ready = function(){ 1703 | // Only do this once 1704 | if(past_messages_loaded == false){ 1705 | // Now that we know the user id, we 1706 | // load the messages 1707 | // (and we will be able to mark "own" messages) 1708 | socket.emit("load more messages",0); 1709 | past_messages_loaded = true; 1710 | } 1711 | }; 1712 | 1713 | function get_value(){ 1714 | return textarea.value; 1715 | } 1716 | 1717 | function scroll_bottom(){ 1718 | log.scrollTop = log.scrollHeight; 1719 | } 1720 | 1721 | textarea.onkeydown = function(e){ 1722 | if(e.keyCode == 13 && !e.shiftKey){ 1723 | e.preventDefault(); 1724 | submit(); 1725 | } 1726 | } 1727 | 1728 | exports.resize = resize; 1729 | 1730 | /* 1731 | Note: This is full of ugly hacks to position and size 1732 | The chat elements. 1733 | */ 1734 | function resize(proportion){ 1735 | var winw = window.innerWidth; 1736 | var winh = window.innerHeight; 1737 | var proportion = proportion || 1/3; 1738 | var scroll = window.scrollY || 0; 1739 | 1740 | /* set with to one column + margin */ 1741 | var w = (parseInt(winw)/4) - 10; 1742 | 1743 | var button_width = button.clientWidth; 1744 | 1745 | var chat_header = 15; 1746 | var input_height = 40; 1747 | var input_width = w - button_width - 60; 1748 | var chat_height = parseInt(proportion * winh - input_height); 1749 | 1750 | textarea.style.width = (input_width)+"px"; 1751 | 1752 | log.style.height = ( 1753 | chat_height - input_height - chat_header - 10 1754 | ) + "px"; 1755 | } 1756 | 1757 | resize(); 1758 | 1759 | window.addEventListener("resize", resize); 1760 | 1761 | button.addEventListener("click",submit); 1762 | 1763 | socket.on("new message", function(data){ 1764 | var el = render_message(data); 1765 | log.appendChild(el); 1766 | scroll_bottom(); 1767 | }); 1768 | 1769 | socket.on("own message", function(data){ 1770 | var el = render_message(data,true); 1771 | log.appendChild(el); 1772 | scroll_bottom(); 1773 | }); 1774 | 1775 | socket.on("past messages", function(messages){ 1776 | var user_id = user.get_public_id(); 1777 | 1778 | for(var i = messages.length - 1; i >= 0; i--){ 1779 | var data = JSON.parse(messages[i]); 1780 | var own = false; 1781 | 1782 | if(data.public_id == user_id){ 1783 | own = true; 1784 | } 1785 | 1786 | var el = render_message(data, own); 1787 | 1788 | if(log.children[0] != undefined){ 1789 | log.insertBefore(el, log.children[0]); 1790 | } else { 1791 | log.appendChild(el); 1792 | } 1793 | } 1794 | 1795 | // While this is used only at page load: 1796 | scroll_bottom(); 1797 | }); 1798 | 1799 | function render_message(data, own){ 1800 | var el; 1801 | if(own){ 1802 | el = render("livechat-sent-message"); 1803 | } else { 1804 | el = render("livechat-received-message"); 1805 | } 1806 | 1807 | // Set the content 1808 | var message = subqsa(el, ".content")[0]; 1809 | 1810 | // Use textContent to avoid script injection 1811 | message.textContent = data.message; 1812 | 1813 | // Clickable links 1814 | message.innerHTML = message.innerHTML 1815 | .replace(/(https?\:\/\/[^\n ]*)/g,"$1"); 1816 | 1817 | var sender = subqsa(el, ".sender")[0]; 1818 | 1819 | // Put sender nickname 1820 | sender.textContent = data.sender; 1821 | 1822 | // Remove newline at begining and end of string 1823 | message.innerHTML = message.innerHTML.replace(/^[\s\n]*/g,""); 1824 | // String end 1825 | message.innerHTML = message.innerHTML.replace(/[\s\n]*$/g,""); 1826 | // Replace newline inside message to
1827 | message.innerHTML = message.innerHTML.replace(/\n/g,"
"); 1828 | 1829 | return el; 1830 | } 1831 | 1832 | // Send chat message 1833 | function submit(){ 1834 | var val = get_value(); 1835 | 1836 | if(val != ""){ 1837 | 1838 | // Only whitespace? 1839 | if(val.match(/[^\s\n]/) == null){ 1840 | return; 1841 | } 1842 | 1843 | socket.emit("new message",{ 1844 | message: val 1845 | }); 1846 | 1847 | textarea.value = ""; 1848 | } 1849 | } 1850 | 1851 | return exports; 1852 | } 1853 | 1854 | function User(){ 1855 | var exports = {}; 1856 | 1857 | var public_id = ""; 1858 | 1859 | exports.has_id = function(){ 1860 | if(public_id == ""){ 1861 | return false; 1862 | } 1863 | if(public_id == ""){ 1864 | return false; 1865 | } 1866 | return true; 1867 | } 1868 | 1869 | exports.get_public_id = function(){ 1870 | return public_id; 1871 | } 1872 | 1873 | exports.set_public_id = function(id){ 1874 | public_id = id; 1875 | } 1876 | 1877 | return exports; 1878 | } 1879 | 1880 | var href = window.location.href; 1881 | 1882 | var is_sheet = href.match(/\/sheet\/(.*)/); 1883 | var is_landing = qsa(".landing").length > 0? true: false; 1884 | 1885 | if(is_sheet){ 1886 | // In a sheet 1887 | var namespace = /\/sheet\/(.*)/g.exec(href)[1]; 1888 | 1889 | var user = User(); 1890 | 1891 | // Start everything 1892 | // Start calculator 1893 | var calc = livecalc(qsa("livecalc")[0], namespace, user); 1894 | var chat = livechat(qsa("livechat")[0], namespace, calc.socket, user); 1895 | calc.set_chat(chat); 1896 | 1897 | resizeable_sidebar_box(function(proportion){ 1898 | chat.resize(1-proportion); 1899 | }); 1900 | 1901 | function resizeable_sidebar_box(callback){ 1902 | // Make chat + palette resizable 1903 | var resizable_header = qsa(".sidebar-resize-header")[0]; 1904 | var dragging = false; 1905 | var initial_pos = 0; 1906 | var upper_box = calc.palette; 1907 | var lower_box = chat.root_el; 1908 | 1909 | resizable_header.addEventListener("mousedown",function(e){ 1910 | e.preventDefault(); 1911 | dragging = true; 1912 | }); 1913 | 1914 | 1915 | resizable_header.addEventListener("mouseup",function(e){ 1916 | dragging = false; 1917 | }); 1918 | 1919 | document.body.addEventListener("mousemove",function(e){ 1920 | if(dragging){ 1921 | var current_pos = e.clientY; 1922 | var proportion = current_pos / window.innerHeight; 1923 | 1924 | proportion = clip(proportion, 0.2,0.8); 1925 | 1926 | var upper_proportion = 1927 | parseInt(proportion * 100) + "%"; 1928 | var lower_proportion = 1929 | parseInt((1-proportion) * 100) + "%"; 1930 | 1931 | upper_box.style.height = upper_proportion; 1932 | lower_box.style.height = lower_proportion; 1933 | 1934 | callback(proportion); 1935 | 1936 | function clip(val, min, max){ 1937 | if(val > min){ 1938 | return val < max? val :max; 1939 | } else { 1940 | return min; 1941 | } 1942 | } 1943 | } 1944 | }); 1945 | } 1946 | 1947 | // Start documentation 1948 | init_doc(calc); 1949 | } else if (is_landing){ 1950 | // TODO: create something nice but easy on CPU for background 1951 | } 1952 | 1953 | /** 1954 | modal 1955 | 1956 | Example: 1957 | 1958 | var buttons = [ 1959 | { 1960 | text: "Accept", 1961 | action: function(modal){ 1962 | modal.close(); 1963 | } 1964 | } 1965 | ]; 1966 | */ 1967 | function modal(message, buttons){ 1968 | var modal = render("modal-inform").children[0]; 1969 | var overlay = render("modal-overlay").children[0]; 1970 | var content = subqsa(modal, ".content p")[0]; 1971 | var buttons_container = subqsa(modal, ".buttons")[0]; 1972 | var buttons = buttons || []; 1973 | 1974 | content.textContent = message; 1975 | document.body.appendChild(overlay); 1976 | overlay.appendChild(modal); 1977 | 1978 | var exports = {}; 1979 | 1980 | exports.close = function(){ 1981 | modal.parentNode.removeChild(modal); 1982 | overlay.parentNode.removeChild(overlay); 1983 | } 1984 | 1985 | for(var i = 0; i < buttons.length; i++){ 1986 | var button = dom(""); 1987 | var callback = buttons[i].action || function(){}; 1988 | var text = buttons[i].text || "No button text"; 1989 | 1990 | button.textContent = text; 1991 | 1992 | enable_click(button, callback); 1993 | 1994 | buttons_container.appendChild(button); 1995 | } 1996 | 1997 | function enable_click(button, callback){ 1998 | // Onclick: Call callback with modal as argument 1999 | button.addEventListener("click", function(){ 2000 | callback(exports); 2001 | }); 2002 | } 2003 | 2004 | return exports; 2005 | } 2006 | 2007 | function modal_inform(message){ 2008 | var buttons = [ 2009 | { 2010 | text: "Accept", 2011 | action: function(modal){ 2012 | modal.close(); 2013 | } 2014 | } 2015 | ]; 2016 | 2017 | return modal(message, buttons); 2018 | } 2019 | 2020 | /** 2021 | callback(bool: answer) (true == yes) 2022 | */ 2023 | function modal_yesno(message, callback){ 2024 | var buttons = [ 2025 | { 2026 | text: "Yes", 2027 | action: function(modal){ 2028 | callback(true); 2029 | modal.close(); 2030 | } 2031 | }, 2032 | { 2033 | text: "No", 2034 | action: function(modal){ 2035 | callback(false); 2036 | modal.close(); 2037 | } 2038 | } 2039 | ]; 2040 | 2041 | return modal(message, buttons); 2042 | } 2043 | 2044 | console.log("Hello!"); 2045 | console.log("Fork me on http://github.com/antoineMoPa/livecalc !"); 2046 | --------------------------------------------------------------------------------