{
14 | static defaultProps = {
15 | duration: 1000,
16 | };
17 |
18 | node: ?HTMLElement;
19 | finalFoldNode: ?HTMLElement;
20 |
21 | componentDidUpdate(prevProps: Props) {
22 | const { onCompleteFolding } = this.props;
23 |
24 | if (!prevProps.isFolded && this.props.isFolded && this.finalFoldNode) {
25 | this.finalFoldNode.addEventListener('animationend', onCompleteFolding);
26 | }
27 | }
28 |
29 | componentWillUnmount() {
30 | const { onCompleteFolding } = this.props;
31 |
32 | if (this.finalFoldNode) {
33 | this.finalFoldNode.removeEventListener('animationend', onCompleteFolding);
34 | }
35 | }
36 |
37 | renderOriginal() {
38 | const { front, isFolded } = this.props;
39 |
40 | return (
41 | (this.node = node)}
43 | style={{ opacity: isFolded ? 0 : 1 }}
44 | >
45 | {front}
46 |
47 | );
48 | }
49 |
50 | renderFoldedCopy() {
51 | const { back, duration } = this.props;
52 | const { node } = this;
53 |
54 | // If we weren't able to capture a ref to the node, we can't do any of this
55 | // However, I think that's impossible? This is just for Flow.
56 | if (!node) {
57 | return;
58 | }
59 |
60 | const { width, height } = node.getBoundingClientRect();
61 |
62 | const foldHeights = [height * 0.35, height * 0.35, height * 0.3];
63 |
64 | // HACK: using top: 0 and left: 0 because this is mounted within a
65 | // transformed container, which means that position: fixed doesn't work
66 | // properly. If you want to use this in an app, you'll likely wish to use
67 | // the top/left from node.getBoundingClientRect.
68 | return (
69 |
70 | (this.finalFoldNode = node)}
72 | duration={duration}
73 | foldHeight={foldHeights[0]}
74 | >
75 |
76 |
80 |
81 | {back}
82 |
83 |
84 |
85 |
86 |
90 |
91 |
92 |
93 |
98 |
99 |
103 |
104 |
105 |
106 |
107 | );
108 | }
109 |
110 | render() {
111 | return (
112 |
113 | {this.renderOriginal()}
114 | {this.props.isFolded && this.renderFoldedCopy()}
115 |
116 | );
117 | }
118 | }
119 |
120 | const foldBottomUp = keyframes`
121 | from {
122 | transform-origin: top center;
123 | transform: perspective(1000px) rotateX(0deg);
124 | }
125 | to {
126 | transform-origin: top center;
127 | transform: perspective(1000px) rotateX(180deg);
128 | }
129 | `;
130 |
131 | const foldTopDown = keyframes`
132 | from {
133 | transform-origin: bottom center;
134 | transform: perspective(1000px) rotateX(0deg);
135 | }
136 | to {
137 | transform-origin: bottom center;
138 | transform: perspective(1000px) rotateX(-180deg);
139 | }
140 | `;
141 |
142 | const Wrapper = styled.div`
143 | position: fixed;
144 | z-index: 10000;
145 | `;
146 |
147 | const FoldBase = styled.div`
148 | position: absolute;
149 | left: 0;
150 | right: 0;
151 | `;
152 |
153 | const TopFold = styled(FoldBase)`
154 | z-index: 3;
155 | top: 0;
156 | height: ${props => Math.round(props.foldHeight)}px;
157 | animation: ${foldTopDown} ${props => props.duration * 0.8}ms forwards
158 | ${props => props.duration * 0.33}ms;
159 | transform-style: preserve-3d;
160 | `;
161 |
162 | const MiddleFold = styled(FoldBase)`
163 | z-index: 1;
164 | top: ${props => Math.round(props.offsetTop)}px;
165 | height: ${props => Math.round(props.foldHeight)}px;
166 | `;
167 |
168 | const BottomFold = styled(FoldBase)`
169 | z-index: 2;
170 | top: ${props => Math.round(props.offsetTop)}px;
171 | height: ${props => Math.round(props.foldHeight)}px;
172 | animation: ${foldBottomUp} ${props => props.duration}ms forwards;
173 | transform-style: preserve-3d;
174 | `;
175 |
176 | const HideOverflow = styled.div`
177 | position: relative;
178 | height: 100%;
179 | z-index: 2;
180 | overflow: hidden;
181 | `;
182 |
183 | const TopFoldContents = styled.div`
184 | backface-visibility: hidden;
185 | `;
186 | const MiddleFoldContents = styled.div`
187 | position: relative;
188 | z-index: 2;
189 | height: ${props => props.height}px;
190 | transform: translateY(${props => Math.round(props.offsetTop) * -1}px);
191 | `;
192 | const BottomFoldContents = styled.div`
193 | position: relative;
194 | z-index: 2;
195 | height: ${props => props.height}px;
196 | transform: translateY(${props => Math.round(props.offsetTop) * -1}px);
197 | backface-visibility: hidden;
198 | `;
199 |
200 | const TopFoldBack = styled.div`
201 | position: absolute;
202 | z-index: 1;
203 | top: 0;
204 | left: 0;
205 | width: 100%;
206 | height: 100%;
207 | transform: rotateX(180deg);
208 | background: rgba(255, 255, 255, 0.95);
209 | backface-visibility: hidden;
210 | `;
211 |
212 | const BottomFoldBack = styled.div`
213 | position: absolute;
214 | z-index: 1;
215 | top: 0;
216 | left: 0;
217 | width: 100%;
218 | height: 100%;
219 | transform: rotateX(180deg);
220 | background: rgba(255, 255, 255, 0.95);
221 | backface-visibility: hidden;
222 | box-shadow: 0px -30px 50px -20px rgba(0, 0, 0, 0.2);
223 | `;
224 |
225 | export default Foldable;
226 |
--------------------------------------------------------------------------------
/src/components/EmailProvider/EmailProvider.data.js:
--------------------------------------------------------------------------------
1 | // @flow
2 | import { generators, create, createMany } from 'sharkhorse';
3 |
4 | import { sample } from '../../utils';
5 |
6 | import avatar1 from '../../assets/avatars/avatar-1.jpg';
7 | import avatar2 from '../../assets/avatars/avatar-2.jpg';
8 | import avatar3 from '../../assets/avatars/avatar-3.jpg';
9 | import avatar4 from '../../assets/avatars/avatar-4.jpg';
10 | import avatar5 from '../../assets/avatars/avatar-5.jpg';
11 | import avatar6 from '../../assets/avatars/avatar-6.jpg';
12 | import avatar7 from '../../assets/avatars/avatar-7.jpg';
13 | import avatar8 from '../../assets/avatars/avatar-8.jpg';
14 | import avatar9 from '../../assets/avatars/avatar-9.jpg';
15 | import avatar10 from '../../assets/avatars/avatar-10.jpg';
16 | import avatar11 from '../../assets/avatars/avatar-11.jpg';
17 | import avatar12 from '../../assets/avatars/avatar-12.jpg';
18 | import avatar13 from '../../assets/avatars/avatar-13.jpg';
19 | import avatar14 from '../../assets/avatars/avatar-14.jpg';
20 | import avatar15 from '../../assets/avatars/avatar-15.jpg';
21 | import avatar16 from '../../assets/avatars/avatar-16.jpg';
22 | import avatarWheeler from '../../assets/avatars/wheeler.jpg';
23 | import avatarDodds from '../../assets/avatars/dodds.jpg';
24 | import avatarNickyCase from '../../assets/avatars/nickycase.jpg';
25 | import avatarKermit from '../../assets/avatars/kermit.gif';
26 | import avatarHydroQuebec from '../../assets/avatars/hydro-quebec.jpg';
27 |
28 | import type { UserData, EmailData, BoxId } from '../../types';
29 |
30 | const avatarSrcs = [
31 | avatar1,
32 | avatar2,
33 | avatar3,
34 | avatar4,
35 | avatar5,
36 | avatar6,
37 | avatar7,
38 | avatar8,
39 | avatar9,
40 | avatar10,
41 | avatar11,
42 | avatar12,
43 | avatar13,
44 | avatar14,
45 | avatar15,
46 | ];
47 |
48 | const subjects = [
49 | 'RE: Plans next Saturday?',
50 | '"JS Fatigue Fatigue" Fatigue',
51 | "OMG I'm going to be speaking at React Europe!!",
52 | 'Eggcelent Egg Salad recipe, dont share...',
53 | 'FWD: sick yoyo trick',
54 | 'Carbonated water: delicious or sinister?!',
55 | 'Going rogue: fixing bugs under-the-table',
56 | ];
57 |
58 | const previews = [
59 | "Hi Marcy, are we still on for that pool party on Saturday? I know John's already got his swimming trunks on.",
60 | 'Anyone else getting tired of hearing people talk about being tired of hearing people talk about JS fatigue?',
61 | 'Wooo so excited, will be talking about Whimsy at React Europe.',
62 | "Ok Tom, I'm warning you: This Egg Salad recipe will BLOW. YOUR. MIND!! It's a family secret so please NO SOCIAL MEDIA",
63 | 'Check out this SICK yoyo trick. Wow!',
64 | "What's the deal with carbonated water, eh? Is it actually just carbon in water or are those bubbles up to something",
65 | "Hey peeps, keep this underground but I'm GOING ROGUE and fixing bugs outside the sprint!?!!!!",
66 | ];
67 |
68 | const UserFactory = {
69 | firstName: generators.name().first(),
70 | lastName: generators.name().last(),
71 | email: generators.email(),
72 | };
73 |
74 | const EmailFactory = {
75 | id: generators.sequence(),
76 | from: UserFactory,
77 |
78 | body: generators.lorem().paragraphs(6),
79 | };
80 |
81 | const BOX_IDS: Array = ['inbox', 'outbox', 'drafts'];
82 |
83 | export const getRandomAvatar = () => avatar16;
84 |
85 | export const generateUser = (overrides: any = {}) => {
86 | const factoryUser = create(UserFactory);
87 |
88 | return {
89 | name: `${factoryUser.firstName} ${factoryUser.lastName}`,
90 | email: factoryUser.email,
91 | avatarSrc: sample(avatarSrcs),
92 | ...overrides,
93 | };
94 | };
95 |
96 | export const generateData = (
97 | userData: UserData,
98 | num: number
99 | ): Map => {
100 | let time = new Date();
101 |
102 | const inboxEmails = [
103 | {
104 | id: 'b',
105 | boxId: 'inbox',
106 | to: userData,
107 | from: {
108 | name: 'Gary Samsonite',
109 | email: 'gary@samsoniteagricultural.com',
110 | avatarSrc: avatar1,
111 | },
112 | timestamp: time - 4000000,
113 | subject: 'Goat-taming kit MIA',
114 | body:
115 | "Greetings, I ordered one of your goat taming kits last week, and I notice it hasn't been shipped yet. I don't have time for this kind of behavior, please let me know when the transaction will be complete.\n\nThanks,\nGary Sampsonite",
116 | },
117 | {
118 | id: 'c',
119 | boxId: 'inbox',
120 | to: userData,
121 | from: {
122 | name: 'Hydro Québec',
123 | email: 'no-reply@hydroquebec.qc.ca',
124 | avatarSrc: avatarHydroQuebec,
125 | },
126 | timestamp: time - 8500000,
127 | subject: 'Your bill is ready',
128 | body:
129 | 'Hello,\n\nYour electricity bill is ready. Please pay $150 by June 2nd.',
130 | },
131 | {
132 | id: 'd',
133 | boxId: 'inbox',
134 | to: userData,
135 | from: {
136 | name: 'Helen George',
137 | email: 'helen@gmail.com',
138 | avatarSrc: avatar2,
139 | },
140 | timestamp: time - 12500000,
141 | subject: '12 MILLION USD TO HUMANITARIAN MISSION HELP NEEDED',
142 | body:
143 | 'GOOD DAY.\n\nURGENT - HELP ME DISTRIBUTE MY $12 MILLION TO HUMANITARIAN.\n\nTHIS MAIL MIGHT COME TO YOU AS A SURPRISE AND THE TEMPTATION TO IGNORE IT AS UNSERIOUS COULD COME INTO YOUR MIND BUT PLEASE CONSIDER IT A DIVINE WISH AND ACCEPT IT WITH A DEEP SENSE OF HUMILITY. I AM MRS HELEN GEORGE AND I AM A 61 YEARS OLD WOMAN. I AM A SOUTH AFRICAN MARRIED TO A SIERRA LEONIA.\n\nI WAS THE PRESIDENT/CEO OF OIL COMPANY INTERNATIONAL-AN OIL SERVICING COMPANY IN JOHANNESBURG. I WAS ALSO MARRIED WITH NO CHILD.\n\nMY HUSBAND DIED 3 YEARS AGO. BEFORE THIS HAPPENED MY BUSINESS AND CONCERN FOR MAKING MONEY WAS ALL I WAS LIVING FOR AND I NEVER REALLY CARED ABOUT OTHER PEOPLE. BUT SINCE THE LOSS OF MY HUSBAND AND ALSO BECAUSE I HAD HAVE NO CHILD TO CALL MY OWN, I HAVE FOUND A NEW DESIRE TO ASSIST THE HELPLESS, I HAVE BEEN HELPING ORPHANS IN ORPHANAGES/MOTHERLESS OMES/HUMANITARIANS. I HAVE DONATED SOME MONEY TO ORPHANS IN SUDAN,ETHIOPIA, CAMEROON, SPAIN, AUSTRIA, GERMANY AND SOME ASIAN COUNTRIES.\n\nIN SUMMARY:- I HAVE 12,000,000.00 (TWELVE MILLION) U. S. DOLLARS WHICH I DEPOSITED IN A SECURITY COMPANY IN COTONOU BENIN REPUBLIC AS A FAMILY TREASURE & ARTEFACTS, PLEASE I WANT YOU TO NOTE THAT THE SECURITY COMPANY DOES NOT KNOW THE REAL CONTENT TO BE MONEY AND I WANT YOU TO ASSIST ME IN CLAIMING THE CONSIGNMENT & DISTRIBUTING THE MONEY TO CHARITY ORGANIZATIONS, I AGREE TO REWARD YOU WITH PART OF THE MONEY FOR YOUR ASSISTANCE, KINDNESS AND PARTICIPATION IN THIS GODLY PROJECT. BEFORE I BECAME ILL, I KEPT $12 MILLION IN A LONG-TERM DEPOSIT IN A SECURITY COMPANY WHICH I DECLARED AS A FAMILY TREASURE ARTIFIARTS.I AM IN THE HOSPITAL WHERE I HAVE BEEN UNDERGOING TREATMENT FOR OESOPHAGEAL CANCER AND MY DOCTORS HAVE TOLD ME THAT I HAVE ONLY A FEW MONTHS TO LIVE. IT IS MY LAST WISH TO SEE THIS MONEY DISTRIBUTED TO CHARITY ORGANIZATIONS.',
144 | },
145 | {
146 | id: 'e',
147 | boxId: 'inbox',
148 | to: userData,
149 | from: {
150 | name: 'Kent C. Dodds',
151 | email: 'kent@email.address',
152 | avatarSrc: avatarDodds,
153 | },
154 | timestamp: time - 27000000,
155 | subject: 'Mixing Component Patterns',
156 | body:
157 | 'This last week I gave three workshops at Frontend Masters:\n\n-⚛️ 💯 Advanced React Patterns\n-📚 ⚠️ Testing Practices and Principles\n-⚛️ ⚠️ Testing React Applications\n\nIf you’re a Frontend Masters subscriber you can watch the unedited version of these courses now. Edited courses should be available for these soon.',
158 | },
159 | {
160 | id: 'f',
161 | boxId: 'inbox',
162 | to: userData,
163 | from: {
164 | name: 'Nicky Case',
165 | email: 'ncase@email.address',
166 | avatarSrc: avatarNickyCase,
167 | },
168 | timestamp: time - 50000000,
169 | subject: 'How do we learn? A zine.',
170 | body:
171 | 'So, you want to understand the world, and/or help others understand the world. Sadly, there are a lot of misconceptions about how people learn. Thankfully, COGNITIVE SCIENCE is showing us what _really_ works! And the first, core idea to get is...',
172 | },
173 | {
174 | id: 'g',
175 | boxId: 'inbox',
176 | to: userData,
177 | from: {
178 | name: 'Kermit',
179 | email: 'kermit@frog.com',
180 | avatarSrc: avatarKermit,
181 | },
182 | timestamp: time - 75000000,
183 | subject: 'Ribbit, ribbit, ribbit',
184 | body:
185 | 'Ribbit ribbit, croaaaak yip ribbit ribbit. Riiibit ribit ribbbbbit. Ribbit.',
186 | },
187 | ];
188 |
189 | let otherBoxEmails = createMany(EmailFactory, 20).map((data, i) => {
190 | const boxId = i % 2 === 0 ? 'outbox' : 'drafts';
191 |
192 | const subject = subjects[i % subjects.length];
193 | const body = previews[i % previews.length] + '\n' + data.body;
194 | const avatarSrc = avatarSrcs[i % avatarSrcs.length];
195 |
196 | time -= Math.random() * 10000000;
197 |
198 | const generatedContact: UserData = {
199 | name: `${data.from.firstName} ${data.from.lastName}`,
200 | email: data.from.email,
201 | avatarSrc,
202 | };
203 |
204 | return {
205 | id: data.id,
206 | boxId,
207 | from: boxId === 'inbox' ? generatedContact : userData,
208 | to: boxId === 'inbox' ? userData : generatedContact,
209 | timestamp: time,
210 | subject,
211 | body,
212 | unread: false,
213 | };
214 | });
215 |
216 | const emails = [...inboxEmails, ...otherBoxEmails];
217 |
218 | // Sharkhorse's factories return an array, but I'd like to keep my data in a
219 | // map, to simulate a database. Map constructors take an array of tuples,
220 | // with the ID and the item: [ [1, email1], [2, email2], ...]
221 | return new Map(emails.map((item) => [item.id, item]));
222 | };
223 |
--------------------------------------------------------------------------------
/src/components/ComposeEmailContainer/ComposeEmailContainer.js:
--------------------------------------------------------------------------------
1 | // @flow
2 | import React, { PureComponent, Fragment } from 'react';
3 | import produce from 'immer';
4 | import styled from 'styled-components';
5 | import Sound from 'react-sound';
6 |
7 | import { Z_INDICES } from '../../constants';
8 | import { delay } from '../../utils';
9 | // Flow doesn't like MP3s. $FlowFixMe
10 | import wooshSoundSrc from '../../assets/woosh-2.mp3';
11 |
12 | import { AuthenticationConsumer } from '../AuthenticationProvider';
13 | import { ModalConsumer } from '../ModalProvider';
14 | import { NodeConsumer } from '../NodeProvider';
15 | import { EmailConsumer } from '../EmailProvider';
16 | import WindowDimensions from '../WindowDimensions';
17 | import Transport from '../Transport';
18 | import Foldable from '../Foldable';
19 | import ComposeEmail from '../ComposeEmail';
20 | import ComposeEmailEnvelope from '../ComposeEmailEnvelope';
21 | import EtchASketchShaker from '../EtchASketchShaker';
22 |
23 | import type { UserData, EmailData, ComposingEmailData } from '../../types';
24 |
25 | type ComposeEmailStep =
26 | | 'idle'
27 | | 'opening'
28 | | 'open'
29 | | 'folding'
30 | | 'transporting'
31 | | 'clearing';
32 |
33 | type Props = {
34 | /**
35 | * NOTE: The following props are provided by a higher-order component,
36 | * defined at the base of this file.
37 | */
38 | handleClose: () => void,
39 | isOpen: boolean,
40 | replyTo: ?EmailData,
41 | openFromNode: HTMLElement,
42 | outboxNode: HTMLElement,
43 | draftsNode: HTMLElement,
44 | windowWidth: number,
45 | windowHeight: any,
46 | userData: UserData,
47 | addNewEmailToBox: (data: any) => void,
48 | };
49 |
50 | type State = {
51 | status: ComposeEmailStep,
52 | actionBeingPerformed: 'send' | 'save' | 'clear' | 'dismiss' | null,
53 | // `EmailData` is the type for sent email: it includes an ID and timestamp.
54 | // For email we're composing, we just want a subset.
55 | emailData: ComposingEmailData,
56 | };
57 |
58 | class ComposeEmailContainer extends PureComponent {
59 | state = {
60 | status: 'idle',
61 | actionBeingPerformed: null,
62 | emailData: {
63 | from: this.props.userData,
64 | toEmail: '',
65 | subject: '',
66 | body: '',
67 | },
68 | };
69 |
70 | componentWillReceiveProps(nextProps: Props) {
71 | if (!this.props.isOpen && nextProps.isOpen) {
72 | const initialState: $Shape = {
73 | actionBeingPerformed: null,
74 | status: 'opening',
75 | };
76 |
77 | if (nextProps.replyTo) {
78 | initialState.emailData = {
79 | ...initialState.emailData,
80 | toEmail: nextProps.replyTo.from.email,
81 | subject: `RE: ${nextProps.replyTo.subject}`,
82 | };
83 | } else {
84 | initialState.emailData = {
85 | ...initialState.emailData,
86 | toEmail: '',
87 | subject: '',
88 | body: '',
89 | };
90 | }
91 |
92 | this.setState(initialState);
93 | }
94 | }
95 |
96 | setStatePromise = (newState: $Shape) =>
97 | new Promise(resolve => this.setState(newState, resolve));
98 |
99 | updateField = (fieldName: string) => (ev: SyntheticInputEvent<*>) => {
100 | this.setState({
101 | emailData: {
102 | ...this.state.emailData,
103 | [fieldName]: ev.target.value,
104 | },
105 | });
106 | };
107 |
108 | dismiss = () => {
109 | this.setState({ actionBeingPerformed: 'dismiss' });
110 | this.props.handleClose();
111 | };
112 |
113 | handleOpenOrClose = () => {
114 | const { actionBeingPerformed } = this.state;
115 |
116 | const isCreatingNewEmail =
117 | actionBeingPerformed === 'send' || actionBeingPerformed === 'save';
118 |
119 | const nextState = produce(this.state, draftState => {
120 | draftState.status = 'idle';
121 | draftState.actionBeingPerformed = null;
122 |
123 | if (isCreatingNewEmail) {
124 | draftState.emailData.toEmail = '';
125 | draftState.emailData.subject = '';
126 | draftState.emailData.body = '';
127 | }
128 | });
129 |
130 | if (isCreatingNewEmail) {
131 | const boxId = actionBeingPerformed === 'send' ? 'outbox' : 'drafts';
132 | this.props.addNewEmailToBox({ boxId, ...this.state.emailData });
133 | }
134 |
135 | this.setState(nextState);
136 | };
137 |
138 | sendEmail = () => {
139 | this.setState({ actionBeingPerformed: 'send', status: 'folding' });
140 | };
141 |
142 | saveEmail = () => {
143 | this.setState({ actionBeingPerformed: 'save', status: 'folding' });
144 | };
145 |
146 | clearEmail = async () => {
147 | // When clearing the email, we do an etch-a-sketch-like shake, with the
148 | // contents disappearing midway through.
149 | // This sequence is not interruptible, and so we'll do it all inline here.
150 | await this.setStatePromise({
151 | actionBeingPerformed: 'clear',
152 | status: 'clearing',
153 | });
154 |
155 | await delay(1000);
156 |
157 | this.setState({
158 | actionBeingPerformed: null,
159 | status: 'idle',
160 | emailData: {
161 | ...this.state.emailData,
162 | subject: '',
163 | body: '',
164 | },
165 | });
166 | };
167 |
168 | finishAction = () => {
169 | // This is triggerd right after the letter is finished folding, for the
170 | // 'send' action.
171 | // In that case, we want to delay by a bit so that the user has time to see
172 | // the envelope.
173 | window.setTimeout(() => {
174 | this.setState({ status: 'transporting' });
175 |
176 | // This modal's open/close state is actually managed by the parent
177 | // . We can indicate that it should close once our letter
178 | // is "on the way"
179 | this.props.handleClose();
180 | }, 250);
181 | };
182 |
183 | renderFront() {
184 | return (
185 |
186 |
194 |
195 | );
196 | }
197 |
198 | renderBack() {
199 | if (this.state.actionBeingPerformed === 'save') {
200 | return null;
201 | }
202 | return ;
203 | }
204 |
205 | render() {
206 | const {
207 | isOpen,
208 | openFromNode,
209 | outboxNode,
210 | draftsNode,
211 | windowWidth,
212 | windowHeight,
213 | } = this.props;
214 | const { status, actionBeingPerformed } = this.state;
215 |
216 | const toNode = actionBeingPerformed === 'send' ? outboxNode : draftsNode;
217 |
218 | let TransporterStatus = isOpen ? 'open' : 'closed';
219 | if (actionBeingPerformed === 'dismiss') {
220 | TransporterStatus = 'retracted';
221 | }
222 |
223 | return (
224 |
225 |
226 |
227 |
238 |
239 |
247 |
253 |
254 |
255 | );
256 | }
257 | }
258 |
259 | const Backdrop = styled.div`
260 | position: absolute;
261 | z-index: ${Z_INDICES.modalBackdrop};
262 | top: 0;
263 | left: 0;
264 | right: 0;
265 | bottom: 0;
266 | background: black;
267 | opacity: ${props => (props.isOpen ? 0.25 : 0)};
268 | pointer-events: ${props => (props.isOpen ? 'auto' : 'none')};
269 | transition: opacity 1000ms;
270 | `;
271 |
272 | // Thin wrapper which aggregates a bunch of different render-prop data
273 | // providers. This is not a very nice-looking solution, but at the time of
274 | // writing, no native `adopt` solution exists, and libraries like react-adopt
275 | // aren't compelling enough to be worth it for a demo.
276 | const withEnvironmentData = WrappedComponent => (props: any) => (
277 |
278 | {({ userData }) => (
279 |
280 | {({ currentModal, openFromNode, closeModal, isReply }) => (
281 |
282 | {({ nodes }) => (
283 |
284 | {({ selectedEmailId, emails, addNewEmailToBox }) => (
285 |
286 | {({ windowWidth, windowHeight }) => (
287 |
301 | )}
302 |
303 | )}
304 |
305 | )}
306 |
307 | )}
308 |
309 | )}
310 |
311 | );
312 | export default withEnvironmentData(ComposeEmailContainer);
313 |
--------------------------------------------------------------------------------
/src/components/Transport/Transport.js:
--------------------------------------------------------------------------------
1 | // @flow
2 | /**
3 | * This utility component can make its children appear from (or disappear to)
4 | * a given target HTMLElement.
5 | */
6 | import React, { Component } from 'react';
7 | import { Motion, spring } from 'react-motion';
8 | import styled from 'styled-components';
9 |
10 | import {
11 | getPositionDelta,
12 | createAugmentedClientRect,
13 | createAugmentedClientRectFromMinimumData,
14 | } from './Transport.helpers';
15 | import type {
16 | AugmentedClientRect,
17 | MinimumFixedPosition,
18 | } from './Transport.types';
19 |
20 | type Quadrant = 1 | 2 | 3 | 4;
21 |
22 | export type Status = 'open' | 'closed' | 'retracted';
23 |
24 | type SpringSettings = {
25 | stiffness?: number,
26 | damping?: number,
27 | precision?: number,
28 | };
29 |
30 | type Props = {
31 | children: React$Node,
32 | from: HTMLElement,
33 | to: HTMLElement,
34 | status: Status,
35 | springOpenHorizontal: SpringSettings,
36 | springOpenVertical: SpringSettings,
37 | springCloseHorizontal: SpringSettings,
38 | springCloseVertical: SpringSettings,
39 | windowWidth: number,
40 | windowHeight: number,
41 | handleFinishTransportation?: () => any,
42 | };
43 |
44 | type State = {
45 | inTransit: boolean,
46 | position: {
47 | top: ?number,
48 | left: ?number,
49 | right: ?number,
50 | bottom: ?number,
51 | translateX: number,
52 | translateY: number,
53 | scaleX: number,
54 | scaleY: number,
55 | transformOrigin: ?string,
56 | },
57 | };
58 |
59 | class Transport extends Component {
60 | static defaultProps = {
61 | springOpenHorizontal: { stiffness: 150, damping: 20 },
62 | springOpenVertical: { stiffness: 200, damping: 20 },
63 | springCloseHorizontal: { stiffness: 150, damping: 22 },
64 | springCloseVertical: { stiffness: 150, damping: 25 },
65 | };
66 |
67 | childWrapperNode: HTMLElement;
68 | fromRect: ?AugmentedClientRect;
69 | toRect: ?AugmentedClientRect;
70 | childRect: ?AugmentedClientRect;
71 |
72 | state = {
73 | inTransit: false,
74 | position: {
75 | top: null,
76 | left: null,
77 | right: null,
78 | bottom: null,
79 | scaleX: 0,
80 | scaleY: 0,
81 | translateX: 0,
82 | translateY: 0,
83 | transformOrigin: null,
84 | },
85 | };
86 |
87 | componentWillReceiveProps(nextProps: Props) {
88 | const { from, to, windowWidth, windowHeight } = nextProps;
89 |
90 | if (!nextProps.from || !nextProps.to || !this.childWrapperNode) {
91 | return;
92 | }
93 |
94 | const wasJustToggled = this.props.status !== nextProps.status;
95 |
96 | // HACK: So, it's currently possible for the parent to have the status
97 | // change from 'retracted' to 'closed'. While this is technically a new
98 | // state, it should not affect the Transport.
99 | // A PROPER fix would be to add some sort of FSM to control the changes
100 | // allowed between statuses, but for now I'm tackling it here, by just
101 | // ignoring any updates where neither status is `open`.
102 | if (this.props.status !== 'open' && nextProps.status !== 'open') {
103 | return;
104 | }
105 |
106 | if (wasJustToggled) {
107 | this.fromRect = createAugmentedClientRect(
108 | from,
109 | windowWidth,
110 | windowHeight
111 | );
112 | this.toRect = createAugmentedClientRect(to, windowWidth, windowHeight);
113 | this.childRect = createAugmentedClientRect(
114 | this.childWrapperNode,
115 | windowWidth,
116 | windowHeight
117 | );
118 |
119 | const initialPositionState = this.getInitialPositionState(
120 | nextProps.status
121 | );
122 |
123 | this.setState(
124 | {
125 | position: initialPositionState,
126 | },
127 | this.playAnimation
128 | );
129 | }
130 | }
131 |
132 | getInitialPositionState(status: Status) {
133 | const { fromRect, toRect, childRect } = this;
134 |
135 | if (!fromRect || !toRect || !childRect) {
136 | throw new Error('Tried to get position without necessary rects!');
137 | }
138 |
139 | // We want to position the element relative to the relevant node.
140 | // For opening, this is the "from" node. For closing, this is the "to" node.
141 | const relativeRect = status === 'closed' ? toRect : fromRect;
142 |
143 | // Figure out which of the 4 quarters of the screen our child is moving
144 | // to or from.
145 | const quadrant: Quadrant = this.getQuadrant(relativeRect);
146 |
147 | // The `transform-origin` of our child during transit.
148 | const transformOrigin = this.getTransformOrigin(quadrant, status);
149 |
150 | // The "minimum position" is what we need to know for our child's new home.
151 | // Consists of either a `top` or a `down`, and a `left` or a `right`.
152 | // Unlike ClientRect, these are the values in `position: fixed` terms, and
153 | // so the `right` value is the number of pixels between the element and the
154 | // right edge of the viewport.
155 | const minimumPositionData = this.getChildPosition(
156 | quadrant,
157 | relativeRect,
158 | status
159 | );
160 |
161 | // Because our animations use CSS transforms, we need to convert our
162 | // fixed-position coords into an AugmentedClientRect
163 | const pendingChildRect = createAugmentedClientRectFromMinimumData(
164 | minimumPositionData,
165 | childRect.width,
166 | childRect.height,
167 | this.props.windowWidth,
168 | this.props.windowHeight
169 | );
170 |
171 | const { translateX, translateY } = this.getTranslate(
172 | status,
173 | pendingChildRect
174 | );
175 |
176 | return {
177 | ...minimumPositionData,
178 | translateX,
179 | translateY,
180 | scaleX: this.state.position.scaleX,
181 | scaleY: this.state.position.scaleY,
182 | transformOrigin,
183 | };
184 | }
185 |
186 | playAnimation = () => {
187 | const { status } = this.props;
188 |
189 | this.setState({
190 | inTransit: true,
191 | position: {
192 | ...this.state.position,
193 | translateX: 0,
194 | translateY: 0,
195 | scaleX: status === 'open' ? 1 : 0,
196 | scaleY: status === 'open' ? 1 : 0,
197 | },
198 | });
199 | };
200 |
201 | finishPlaying = () => {
202 | this.setState({ inTransit: false });
203 |
204 | if (typeof this.props.handleFinishTransportation === 'function') {
205 | this.props.handleFinishTransportation();
206 | }
207 | };
208 |
209 | getQuadrant(targetRect: ?AugmentedClientRect): Quadrant {
210 | const { windowWidth, windowHeight } = this.props;
211 |
212 | // When expanding from something, we want to use its "opposite" corner.
213 | // Imagine we divide the screen into quadrants:
214 | // ___________
215 | // | 1 | 2 |
216 | // |-----|-----|
217 | // | 3 | 4 |
218 | // ------------
219 | //
220 | // If the target element is in the top-left quadrant (#2), we want to open
221 | // the children from its bottom-right corner. This way, the expande item is
222 | // most likely to fit comfortably on the screen:
223 | //
224 | // ------------------------------|
225 | // | target | |
226 | // /-------- |
227 | // ----------/ |
228 | // | children | |
229 | // ---------- |
230 | // ______________________________|
231 |
232 | if (!targetRect) {
233 | throw new Error('Could not calculate quadrant, no targetRect given');
234 | }
235 |
236 | const windowCenter = {
237 | x: windowWidth / 2,
238 | y: windowHeight / 2,
239 | };
240 |
241 | if (targetRect.centerY < windowCenter.y) {
242 | // top half, left or right
243 | return targetRect.centerX < windowCenter.x ? 1 : 2;
244 | } else {
245 | // bottom half, left or right
246 | return targetRect.centerX < windowCenter.x ? 3 : 4;
247 | }
248 | }
249 |
250 | getTranslate(status: Status, pendingChildRect: AugmentedClientRect) {
251 | /**
252 | * This component uses the FLIP technique.
253 | *
254 | * When our open status changes, we move the node using fixed positioning
255 | * to the `to` node, and then we "invert" that effect by applying an
256 | * immediate, opposite translation.
257 | *
258 | * This method calculates that by comparing the child rect held in state
259 | * with the "pending" childRect, which is about to be applied.
260 | */
261 | const { childRect: currentChildRect } = this;
262 |
263 | if (!currentChildRect) {
264 | throw new Error('Animation started without necessary childRect!');
265 | }
266 |
267 | // We don't have any translation on-open.
268 | // Might change this later, if we add spacing support.
269 | if (status === 'open' || status === 'retracted') {
270 | return { translateX: 0, translateY: 0 };
271 | }
272 |
273 | const [x, y] = getPositionDelta(currentChildRect, pendingChildRect);
274 | return { translateX: x, translateY: y };
275 | }
276 |
277 | getTransformOrigin(quadrant: Quadrant, status: Status) {
278 | // If we're going "to" the target, we want to disappear into its center.
279 | // For this reason, the transform-origin will always be the middle.
280 | if (status === 'closed') {
281 | return 'center center';
282 | }
283 |
284 | // If we're coming "from" the target, the transform-origin depends on the
285 | // quadrant. We want to expand outward from the element, after all.
286 | switch (quadrant) {
287 | case 1:
288 | return 'top left';
289 | case 2:
290 | return 'top right';
291 | case 3:
292 | return 'bottom left';
293 | case 4:
294 | return 'bottom right';
295 | default:
296 | throw new Error(`Unrecognized quadrant: ${quadrant}`);
297 | }
298 | }
299 |
300 | getChildPosition(
301 | quadrant: Quadrant,
302 | targetRect: AugmentedClientRect,
303 | status: Status
304 | ): MinimumFixedPosition {
305 | /**
306 | * Get the fixed position for the child, calculated using the target rect
307 | * for reference.
308 | *
309 | * This depends on two factors:
310 | *
311 | * 1. QUADRANT
312 | * The quadrant affects how the child will be positioned relative to the
313 | * target. In the first quadrant (top-left), the box opens from the
314 | * target's bottom-right corner:
315 | * _____
316 | * | T |
317 | * |_____| _____ T = target
318 | * | C | C = child
319 | * |_____|
320 | *
321 | * When we're in the second quadrant, though, the child opens to the
322 | * _left_ of the target:
323 | * _____
324 | * | T |
325 | * _____ -----
326 | * | C |
327 | * -----
328 | * Effectively, each quadrant causes the child to open from the target's
329 | * _opposite corner_. This is to ensure that the child opens on-screen
330 | * (if it always opened to the top-right, and the target was also in
331 | * the top-right corner, it would render outside of the viewport).
332 | *
333 | * 2. STATUS
334 | * When about to 'open' the child, we want to align the child with the
335 | * target's opposite corner (as shown in 1. QUADRANT).
336 | * When the direction is `to`, though, we want to align the target's
337 | * center-point to the child's center-point:
338 | *
339 | * `from`:
340 | * _______
341 | * | |
342 | * | T |
343 | * | | T = target
344 | * ------- ___ C = child
345 | * | C |
346 | * ---
347 | *
348 | * `to`:
349 | * _______
350 | * | ___ |
351 | * | | C | |
352 | * | --- |
353 | * -------
354 | *
355 | * This has to do with the intended effect: the child should grow from
356 | * the target's corner, but it should shrink into the target's center.
357 | */
358 | const { childRect } = this;
359 |
360 | if (!childRect) {
361 | throw new Error("childRect doesn't exist");
362 | }
363 |
364 | const orientRelativeToCorner = status === 'open' || status === 'retracted';
365 |
366 | switch (quadrant) {
367 | case 1:
368 | return {
369 | top: orientRelativeToCorner
370 | ? targetRect.bottom
371 | : targetRect.centerY - childRect.height / 2,
372 | left: orientRelativeToCorner
373 | ? targetRect.right
374 | : targetRect.centerX - childRect.width / 2,
375 | };
376 | case 2:
377 | return {
378 | top: orientRelativeToCorner
379 | ? targetRect.bottom
380 | : targetRect.centerY - childRect.height / 2,
381 | right: orientRelativeToCorner
382 | ? targetRect.fromBottomRight.left
383 | : targetRect.fromBottomRight.centerX - childRect.width / 2,
384 | };
385 | case 3:
386 | return {
387 | bottom: orientRelativeToCorner
388 | ? targetRect.fromBottomRight.top
389 | : targetRect.fromBottomRight.centerY - childRect.height / 2,
390 | left: orientRelativeToCorner
391 | ? targetRect.right
392 | : targetRect.centerX - childRect.width / 2,
393 | };
394 | case 4:
395 | return {
396 | bottom: orientRelativeToCorner
397 | ? targetRect.fromBottomRight.top
398 | : targetRect.fromBottomRight.centerY - childRect.height / 2,
399 | right: orientRelativeToCorner
400 | ? targetRect.fromBottomRight.left
401 | : targetRect.fromBottomRight.centerX - childRect.width / 2,
402 | };
403 | default:
404 | throw new Error(`Unrecognized quadrant: ${quadrant}`);
405 | }
406 | }
407 |
408 | render() {
409 | const {
410 | status,
411 | children,
412 | springOpenHorizontal,
413 | springOpenVertical,
414 | springCloseHorizontal,
415 | springCloseVertical,
416 | } = this.props;
417 | const { position, inTransit } = this.state;
418 |
419 | const {
420 | top,
421 | left,
422 | right,
423 | bottom,
424 | scaleX,
425 | scaleY,
426 | translateX,
427 | translateY,
428 | transformOrigin,
429 | } = position;
430 |
431 | const springHorizontal =
432 | status === 'closed' ? springCloseHorizontal : springOpenHorizontal;
433 | const springVertical =
434 | status === 'closed' ? springCloseVertical : springOpenVertical;
435 |
436 | return (
437 |
454 | {({ scaleX, scaleY, translateX, translateY }) => (
455 | {
457 | this.childWrapperNode = node;
458 | }}
459 | style={{
460 | top,
461 | left,
462 | bottom,
463 | right,
464 | transform: `
465 | translate(${translateX}px, ${translateY}px)
466 | scale(${Math.max(scaleX, 0)}, ${Math.max(scaleY, 0)})
467 | `,
468 | transformOrigin,
469 | }}
470 | >
471 | {children}
472 |
473 | )}
474 |
475 | );
476 | }
477 | }
478 |
479 | const Wrapper = styled.div`
480 | position: fixed;
481 | z-index: 10000;
482 | `;
483 |
484 | export default Transport;
485 |
--------------------------------------------------------------------------------