Skip to content

Commit 7064bd7

Browse files
queervioletqueerviolet
queerviolet
authored and
queerviolet
committed
Whiteboard demo.
1 parent 8c62a78 commit 7064bd7

File tree

8 files changed

+351
-806
lines changed

8 files changed

+351
-806
lines changed

app/main.jsx

+2
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import firebase from 'APP/fire'
1010

1111
// -- // Demo components // -- //
1212
import Scratchpad from 'APP/demos/scratchpad'
13+
import Whiteboard from 'APP/demos/whiteboard'
1314

1415
// Get the auth API from Firebase.
1516
const auth = firebase.auth()
@@ -57,6 +58,7 @@ render(
5758
<Route path="/" component={App}>
5859
<IndexRedirect to="scratchpad/welcome"/>
5960
<Route path="scratchpad/:title" component={Scratchpad}/>
61+
<Route path="whiteboard/:title" component={Whiteboard}/>
6062
</Route>
6163
<Route path='*' component={NotFound}/>
6264
</Router>,

demos/whiteboard/Canvas.jsx

+72
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
'use strict'
2+
import React from 'react'
3+
import {connect} from 'react-redux'
4+
5+
import {penDown, moveTo, penUp} from './reducers'
6+
7+
// A single mark on the whiteboard.
8+
export const Stroke = ({stroke}) => <path style={stroke.style} d={stroke.data} />
9+
10+
export const Canvas = ({
11+
// Props from state
12+
strokes,
13+
currentStroke,
14+
15+
// Actions
16+
penDown,
17+
moveTo,
18+
penUp,
19+
20+
// These event handlers map x and y appropriately,
21+
// which turns out to be irritating.
22+
onTouchStart=withLocalCoordinates(penDown),
23+
onTouchMove=withLocalCoordinates(moveTo),
24+
onTouchEnd=withLocalCoordinates(penUp),
25+
26+
// Root SVG element style
27+
style={
28+
width: '100%',
29+
height: '100%',
30+
minWidth: '1440px',
31+
minHeight: '1080px',
32+
}
33+
}) =>
34+
<svg
35+
style={style}
36+
onTouchStart={onTouchStart}
37+
onTouchMove={onTouchMove}
38+
onTouchEnd={onTouchEnd}
39+
onMouseDown={onTouchStart}
40+
onMouseMove={onTouchMove}
41+
onMouseUp={onTouchEnd}>
42+
{
43+
strokes.map((stroke, idx) => <Stroke key={idx} stroke={stroke}/>)
44+
}
45+
{currentStroke && <Stroke stroke={currentStroke}/>}
46+
</svg>
47+
48+
function closest(Type, event) {
49+
let e = event.target
50+
while (e.parentElement) {
51+
if (e instanceof Type) return e
52+
e = e.parentElement
53+
}
54+
return null
55+
}
56+
57+
function withLocalCoordinates(actionCreator) {
58+
return evt => {
59+
// Get the bounding rectangle of the svg element in screen coordinates
60+
const rect = closest(window.SVGSVGElement, evt).getBoundingClientRect()
61+
, x = evt.pageX - rect.left
62+
, y = evt.pageY - rect.top
63+
64+
// Call the action creator with the right coordinates.
65+
return actionCreator(x, y)
66+
}
67+
}
68+
69+
export default connect(
70+
({strokes, currentStroke}) => ({strokes, currentStroke}),
71+
{penDown, moveTo, penUp},
72+
)(Canvas)

demos/whiteboard/Whiteboard.jsx

+124
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import React from 'react'
2+
3+
import {createStore, applyMiddleware} from 'redux'
4+
import {composeWithDevTools} from 'redux-devtools-extension'
5+
import {Provider} from 'react-redux'
6+
7+
import createLogger from 'redux-logger'
8+
import thunkMiddleware from 'redux-thunk'
9+
10+
import Canvas from './Canvas'
11+
import reducer from './reducers'
12+
13+
export default class extends React.Component {
14+
componentDidMount() {
15+
this.mountStoreAtRef(this.props.fireRef)
16+
}
17+
18+
componentWillReceiveProps(incoming, outgoing) {
19+
this.mountStoreAtRef(incoming.fireRef)
20+
}
21+
22+
componentWillUnmount() {
23+
this.unsubscribe && this.unsubscribe()
24+
}
25+
26+
mountStoreAtRef(ref) {
27+
if (this.state && this.state.store) {
28+
// If we already have a store, let's destroy it.
29+
30+
// First, unsubscribe our firebase listener.
31+
this.unsubscribe && this.unsubscribe()
32+
this.unsubscribe = null
33+
34+
// Then, do this annoying thing.
35+
//
36+
// If we don't do this, React does what React does, determines that
37+
// our render tree still has a <Provider>, and it should just send
38+
// that Provider new props. Unfortunately, <Provider> doesn't support
39+
// changing store on the fly. 😡
40+
//
41+
// So, here's a hack. We set the store to null. This forces a re-render
42+
// during which we return null, unmounting our Provider and everything
43+
// under it. Then, in the next tick, we actually mount a new <Provider>
44+
// with our new store.
45+
//
46+
// The lag is imperceptible.
47+
this.setState({store: null})
48+
return process.nextTick(() => this.mountStoreAtRef(ref))
49+
}
50+
51+
const store = createStore(
52+
reducer,
53+
composeWithDevTools(
54+
applyMiddleware(
55+
createLogger({collapsed: true}),
56+
thunkMiddleware,
57+
// We're defining our own middleware! Inline! Oh god! This middleware is going to
58+
// sync our actions to Firebase.
59+
//
60+
// The signature for Redux middleware is:
61+
//
62+
// (store: Redux Store) -> (next: Next Dispatch Function) -> New Dispatch Function
63+
//
64+
// Or, as I like to remember it, store => next => action. Notice that ultimately,
65+
// the middleware returns a new dispatch function. That's how you should think of
66+
// Redux middleware—it gets the old dispatch function (perhaps the store's base dispatch,
67+
// which calls the reducer and updates the state, or the dispatch function returned
68+
// by the middleware next in the applyMiddleware chain)—and returns a new dispatch
69+
// function. (So function. Wow. 🐶)
70+
//
71+
// This lets us manipulate the behavior of redux at various points.
72+
store => next => {
73+
// Whenever an action is pushed into Firebase, dispatch it
74+
// to the reducer (or the next middleware).
75+
const listener = ref.on('child_added', snapshot => next(snapshot.val()))
76+
this.unsubscribe = () => ref.off('child_added', listener)
77+
78+
// Our new dispatch function is super simple—it pushes actions to Firebase,
79+
// unless they have a truthy doNotSync property.
80+
//
81+
// "But what if our connection to Firebase is down?" Firebase handles this.
82+
// It will still call your local listeners, then eventually sync the data
83+
// with the server.
84+
return action => {
85+
if (action.doNotSync) { return next(action) }
86+
return ref.push(action)
87+
}
88+
}
89+
)
90+
)
91+
)
92+
this.setState({store})
93+
}
94+
95+
clear = () => {
96+
// Blow away the journal
97+
this.props.fireRef.set(null)
98+
// Reload the store
99+
this.mountStoreAtRef(this.props.fireRef)
100+
}
101+
102+
render() {
103+
const {store} = this.state || {}
104+
, {children} = this.props
105+
if (!store) return null
106+
// So, this is unexpected.
107+
//
108+
// We're used to seeing <Provider> at the top of an App. But there's no rule
109+
// that has to be the case. In a Firebase app, it makes more sense for the app's
110+
// "shell" state to be managed with Firebase (and React Router). The shell
111+
// figures out what fireRef to give us based on where the user is in the app,
112+
// then we create a <Provider> pointing at a store whose actions are synced to
113+
// that Firebase ref.
114+
//
115+
// If our fireRef changes, we'll throw this store state away and create a new one.
116+
// That's fine!
117+
return <Provider store={store}>
118+
<div>
119+
<button onClick={this.clear}>clear</button>
120+
<Canvas/>
121+
</div>
122+
</Provider>
123+
}
124+
}

demos/whiteboard/index.jsx

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import React from 'react'
2+
import {Route} from 'react-router'
3+
import firebase from 'APP/fire'
4+
const db = firebase.database()
5+
6+
import Whiteboard from './Whiteboard'
7+
8+
// This component is a little piece of glue between React router
9+
// and our whiteboard component. It takes in props.params.title, and
10+
// shows the whiteboard along with that title.
11+
export default ({params: {title}}) =>
12+
<div>
13+
<h1>{title}</h1>
14+
{/* Here, we're passing in a Firebase reference to
15+
/whiteboards/$whiteboardTitle. This is where the whiteboard is
16+
stored in Firebase. Each whiteboard is an array of actions that
17+
users have dispatched into the whiteboard. */}
18+
<Whiteboard fireRef={db.ref('whiteboards').child(title)}/>
19+
</div>

demos/whiteboard/reducers/index.jsx

+81
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import {combineReducers} from 'redux'
2+
import {List} from 'immutable'
3+
4+
// -- // -- // Actions // -- // -- //
5+
export const PEN_DOWN = 'PEN_DOWN'
6+
export const penDown = (x, y) => ({
7+
type: PEN_DOWN,
8+
pos: [x, y]
9+
})
10+
11+
export const MOVE_TO = 'MOVE_TO'
12+
export const moveTo = (x, y) => (dispatch, state) => dispatch({
13+
type: MOVE_TO,
14+
pos: [x, y],
15+
doNotSync: !state().currentStroke, // Don't journal move events unless
16+
// we're drawing a stroke.
17+
})
18+
19+
export const PEN_UP = 'PEN_UP'
20+
export const penUp = (x, y) => ({
21+
type: PEN_UP,
22+
pos: [x, y]
23+
})
24+
25+
// -- // -- // State // -- // -- //
26+
const initial = {
27+
// All the strokes on the whiteboard.
28+
// We're using an immutable list here, which is easier
29+
// to work with in the reducer.
30+
strokes: List(),
31+
32+
// The stroke we are currently drawing.
33+
// Null if we're not drawing, which is how we start off.
34+
currentStroke: null,
35+
}
36+
37+
// -- // -- // Helpers // -- // -- //
38+
// Convert an array of points into an SVG path data string
39+
function svgPathData(points) {
40+
return 'M ' + points
41+
.map(([x, y]) => `${x},${y}`)
42+
.join(' L ')
43+
}
44+
45+
// -- // -- // Reducer // -- // -- //
46+
export default (state=initial, action) => {
47+
let points
48+
switch (action.type) {
49+
case PEN_DOWN:
50+
points = List().push(action.pos)
51+
return {...state,
52+
currentStroke: {
53+
points,
54+
style: {
55+
fill: 'none',
56+
stroke: '#000000',
57+
strokeWidth: '5px'
58+
},
59+
data: svgPathData(points)
60+
}
61+
}
62+
63+
case MOVE_TO:
64+
if (!state.currentStroke) return state
65+
points = state.currentStroke.points.push(action.pos)
66+
return {...state,
67+
currentStroke: {...state.currentStroke,
68+
points,
69+
data: svgPathData(points)
70+
}
71+
}
72+
73+
case PEN_UP:
74+
if (!state.currentStroke) return state
75+
return {...state,
76+
strokes: state.strokes.push(state.currentStroke),
77+
currentStroke: null
78+
}
79+
}
80+
return state
81+
}

demos/whiteboard/reducers/strokes.js

+10
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
"finalhandler": "^1.0.0",
5555
"firebase": "^3.9.0",
5656
"homedir": "^0.6.0",
57+
"immutable": "^3.8.1",
5758
"passport": "^0.3.2",
5859
"passport-facebook": "^2.1.1",
5960
"passport-github2": "^0.1.10",

0 commit comments

Comments
 (0)