├── .editorconfig
├── .gitignore
├── README.md
├── logo.png
├── package.json
├── public
├── _redirects
├── connect.png
├── created.png
├── favicon.ico
├── github.svg
├── google-slide.png
├── google-slides.png
├── google.png
├── index.html
├── manifest.json
├── producthunt.svg
├── robots.txt
├── select.png
└── zeplin.png
├── src
├── App.js
├── components
│ ├── ConnectCard
│ │ ├── ConnectCard.js
│ │ └── index.js
│ ├── Connected
│ │ ├── Connected.js
│ │ └── index.js
│ ├── CreateSection
│ │ ├── CreateSection.js
│ │ └── index.js
│ ├── Footer
│ │ ├── Footer.js
│ │ └── index.js
│ ├── GoogleConnectCard
│ │ ├── GoogleConnectCard.js
│ │ └── index.js
│ ├── Header
│ │ ├── Header.js
│ │ └── index.js
│ ├── HomeCard
│ │ ├── HomeCard.js
│ │ └── index.js
│ ├── IntegrationImage
│ │ ├── IntegrationImage.js
│ │ ├── LovePath
│ │ │ ├── LovePath.jsx
│ │ │ └── index.js
│ │ └── index.js
│ ├── PrivateRoute.js
│ ├── ProjectCombobox
│ │ ├── ProjectCombobox.jsx
│ │ └── index.js
│ └── ZeplinConnectCard
│ │ ├── ZeplinConnectCard.js
│ │ └── index.js
├── constants
│ └── index.js
├── index.css
├── index.js
├── layouts
│ └── Main.jsx
├── pages
│ ├── Connect.js
│ ├── Create.js
│ └── Home.js
├── providers
│ ├── AuthProvider.js
│ └── StoreProvider
│ │ ├── StoreProvider.jsx
│ │ ├── actions.js
│ │ ├── index.js
│ │ ├── reducer.js
│ │ ├── selectors.js
│ │ └── types.js
├── services
│ ├── google.js
│ └── zeplin.js
├── setupTests.js
└── utils
│ ├── http.js
│ ├── image.js
│ └── image.test.js
└── yarn.lock
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = space
5 | indent_size = 2
6 | charset = utf-8
7 | trim_trailing_whitespace = true
8 | insert_final_newline = true
9 | end_of_line = lf
10 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 | .env*
25 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 | Zeplin Slides
3 |
4 |
5 |
6 | Create presentations in Google Slides from Zeplin projects
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | ## Development
18 |
19 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app) and [Material UI](https://material-ui.com/).
20 |
21 | ### APIs
22 |
23 | This project uses [Zeplin API](https://docs.zeplin.dev) to fetch Zeplin projects and screens and [Google Slides API](https://developers.google.com/slides) to create presentations in Google Slides.
24 |
25 | ### Available Scripts
26 |
27 | In the project directory, you can run:
28 |
29 | #### `yarn start`
30 |
31 | Runs the app in the development mode.
32 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
33 |
34 | The page will reload if you make edits.
35 | You will also see any lint errors in the console.
36 |
37 | #### `yarn test`
38 |
39 | Launches the test runner in the interactive watch mode.
40 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
41 |
42 | #### `yarn build`
43 |
44 | Builds the app for production to the `build` folder.
45 | It correctly bundles React in production mode and optimizes the build for the best performance.
46 |
--------------------------------------------------------------------------------
/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mertkahyaoglu/zeplin-google-slides/f0f174e542805fadca1e596e6f26d113720c1ffc/logo.png
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "zeplin-slides",
3 | "version": "1.0.0",
4 | "scripts": {
5 | "start": "react-scripts start",
6 | "build": "react-scripts build",
7 | "test": "react-scripts test",
8 | "eject": "react-scripts eject"
9 | },
10 | "dependencies": {
11 | "@material-ui/core": "^4.9.9",
12 | "@material-ui/icons": "^4.9.1",
13 | "@material-ui/lab": "^4.0.0-alpha.48",
14 | "@testing-library/jest-dom": "^4.2.4",
15 | "@testing-library/react": "^9.3.2",
16 | "@testing-library/user-event": "^7.1.2",
17 | "moment": "^2.24.0",
18 | "react": "^16.13.1",
19 | "react-dom": "^16.13.1",
20 | "react-router-dom": "^5.1.2",
21 | "react-scripts": "3.4.1",
22 | "typeface-roboto": "^0.0.75"
23 | },
24 | "eslintConfig": {
25 | "extends": "react-app",
26 | "globals": {
27 | "gapi": true
28 | }
29 | },
30 | "browserslist": {
31 | "production": [
32 | ">0.2%",
33 | "not dead",
34 | "not op_mini all"
35 | ],
36 | "development": [
37 | "last 1 chrome version",
38 | "last 1 firefox version",
39 | "last 1 safari version"
40 | ]
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/public/_redirects:
--------------------------------------------------------------------------------
1 | /* /index.html 200
2 |
--------------------------------------------------------------------------------
/public/connect.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mertkahyaoglu/zeplin-google-slides/f0f174e542805fadca1e596e6f26d113720c1ffc/public/connect.png
--------------------------------------------------------------------------------
/public/created.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mertkahyaoglu/zeplin-google-slides/f0f174e542805fadca1e596e6f26d113720c1ffc/public/created.png
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mertkahyaoglu/zeplin-google-slides/f0f174e542805fadca1e596e6f26d113720c1ffc/public/favicon.ico
--------------------------------------------------------------------------------
/public/github.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/google-slide.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mertkahyaoglu/zeplin-google-slides/f0f174e542805fadca1e596e6f26d113720c1ffc/public/google-slide.png
--------------------------------------------------------------------------------
/public/google-slides.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mertkahyaoglu/zeplin-google-slides/f0f174e542805fadca1e596e6f26d113720c1ffc/public/google-slides.png
--------------------------------------------------------------------------------
/public/google.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mertkahyaoglu/zeplin-google-slides/f0f174e542805fadca1e596e6f26d113720c1ffc/public/google.png
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
17 |
18 |
27 | Zeplin Slides
28 |
29 |
30 |
31 |
35 |
44 |
45 |
46 |
47 |
48 |
58 |
59 |
60 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Zeplin Google Slides",
3 | "name": "Create presentations in Google Slides from Zeplin projects",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "zeplin.png",
12 | "type": "image/png",
13 | "sizes": "256x256"
14 | }
15 | ],
16 | "start_url": ".",
17 | "display": "standalone",
18 | "theme_color": "#000000",
19 | "background_color": "#ffffff"
20 | }
21 |
--------------------------------------------------------------------------------
/public/producthunt.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/public/select.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mertkahyaoglu/zeplin-google-slides/f0f174e542805fadca1e596e6f26d113720c1ffc/public/select.png
--------------------------------------------------------------------------------
/public/zeplin.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mertkahyaoglu/zeplin-google-slides/f0f174e542805fadca1e596e6f26d113720c1ffc/public/zeplin.png
--------------------------------------------------------------------------------
/src/App.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { BrowserRouter as Router, Switch, Route } from "react-router-dom";
3 |
4 | import { AuthProvider } from "./providers/AuthProvider";
5 | import { StoreProvider } from "./providers/StoreProvider";
6 |
7 | import Connect from "./pages/Connect";
8 | import Create from "./pages/Create";
9 | import Home from "./pages/Home";
10 |
11 | import PrivateRoute from "./components/PrivateRoute";
12 |
13 | function App() {
14 | return (
15 |
16 |
17 |
18 |
19 | } />
20 | } />
21 | } />
22 |
23 |
24 |
25 |
26 | );
27 | }
28 |
29 | export default App;
30 |
--------------------------------------------------------------------------------
/src/components/ConnectCard/ConnectCard.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import {
4 | Box,
5 | Card,
6 | CardContent,
7 | Typography,
8 | Button,
9 | CircularProgress,
10 | } from "@material-ui/core";
11 |
12 | export default function ConnectCard({
13 | accountName,
14 | accountEmail,
15 | description,
16 | onConnect,
17 | onDisconnect,
18 | isConnected,
19 | buttonIcon,
20 | authenticating,
21 | }) {
22 | let buttonText = "Connect";
23 | if (isConnected) {
24 | buttonText = "Disconnect";
25 | } else if (authenticating) {
26 | buttonText = "Connecting…";
27 | }
28 |
29 | const buttonStartIcon = authenticating ? (
30 |
31 | ) : (
32 | buttonIcon
33 | );
34 |
35 | return (
36 |
37 |
38 |
39 |
40 | {isConnected ? "Connected to" : "Connect"} {accountName} account
41 |
42 |
43 | {isConnected ? (
44 |
45 | Connected to account {accountEmail}.
46 |
47 | ) : (
48 | description
49 | )}
50 |
51 |
52 |
53 |
65 |
66 |
67 |
68 |
69 | );
70 | }
71 |
--------------------------------------------------------------------------------
/src/components/ConnectCard/index.js:
--------------------------------------------------------------------------------
1 | export { default } from "./ConnectCard";
2 |
--------------------------------------------------------------------------------
/src/components/Connected/Connected.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Avatar, Chip, Box } from "@material-ui/core";
3 |
4 | export default function Connected() {
5 | return (
6 |
7 | }
9 | label="Zeplin"
10 | onDelete={() => console.log()}
11 | />
12 |
15 | }
16 | label="Google"
17 | onDelete={() => console.log()}
18 | />
19 |
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/src/components/Connected/index.js:
--------------------------------------------------------------------------------
1 | export { default } from "./Connected"
2 |
--------------------------------------------------------------------------------
/src/components/CreateSection/CreateSection.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export default function CreateSection() {
4 | return create
;
5 | }
6 |
--------------------------------------------------------------------------------
/src/components/CreateSection/index.js:
--------------------------------------------------------------------------------
1 | export { default } from "./CreateSection";
2 |
--------------------------------------------------------------------------------
/src/components/Footer/Footer.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import { Box, Link } from "@material-ui/core";
4 |
5 | export default function Footer() {
6 | return (
7 |
8 |
13 | Contact
14 |
15 |
22 | GitHub
23 |
24 |
31 | Privacy Policy
32 |
33 |
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/src/components/Footer/index.js:
--------------------------------------------------------------------------------
1 | export { default } from "./Footer";
2 |
--------------------------------------------------------------------------------
/src/components/GoogleConnectCard/GoogleConnectCard.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import { useAuth } from "../../providers/AuthProvider";
4 | import {
5 | onSignInClick,
6 | onSignOutClick,
7 | } from "../../services/google";
8 |
9 | import ConnectCard from "../ConnectCard";
10 |
11 | export default function GoogleConnectCard() {
12 | const { isGoogleConnected, googleUser, setGoogleUser, isAuthenticatingGoogle } = useAuth();
13 |
14 | const onDisconnect = () => {
15 | onSignOutClick();
16 |
17 | setGoogleUser(null);
18 | };
19 |
20 | return (
21 | }
29 | authenticating={isAuthenticatingGoogle}
30 | />
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/src/components/GoogleConnectCard/index.js:
--------------------------------------------------------------------------------
1 | export { default } from "./GoogleConnectCard";
2 |
--------------------------------------------------------------------------------
/src/components/Header/Header.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import { Box, Link } from "@material-ui/core";
4 |
5 | import IntegrationImage from "../IntegrationImage";
6 |
7 | export default function Header() {
8 | return (
9 |
10 |
11 |
17 |
22 |
23 | {/*
28 |
29 |
30 | */}
31 |
32 |
33 |
34 | Zeplin Slides
35 | Create presentations in Google Slides from Zeplin projects
36 |
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/src/components/Header/index.js:
--------------------------------------------------------------------------------
1 | export { default } from "./Header";
2 |
--------------------------------------------------------------------------------
/src/components/HomeCard/HomeCard.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import { Typography, Box, Paper } from "@material-ui/core";
4 |
5 | export default function HomeCard({ title, image, reverse, disableImageBorder }) {
6 | return (
7 |
8 |
9 |
17 |
18 | {title}
19 |
20 |
24 | {image}
25 |
26 |
27 |
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/src/components/HomeCard/index.js:
--------------------------------------------------------------------------------
1 | export { default } from "./HomeCard";
2 |
--------------------------------------------------------------------------------
/src/components/IntegrationImage/IntegrationImage.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import { Box } from "@material-ui/core";
4 |
5 | import LovePath from "./LovePath";
6 |
7 | function IntegrationImage() {
8 | return (
9 |
10 |
15 |
16 |
21 |
22 | );
23 | }
24 |
25 | export default IntegrationImage;
26 |
--------------------------------------------------------------------------------
/src/components/IntegrationImage/LovePath/LovePath.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import { Box } from "@material-ui/core";
4 |
5 | function LovePath() {
6 | return (
7 |
8 |
32 |
33 | );
34 | }
35 |
36 | export default LovePath;
37 |
--------------------------------------------------------------------------------
/src/components/IntegrationImage/LovePath/index.js:
--------------------------------------------------------------------------------
1 | export { default } from "./LovePath";
2 |
--------------------------------------------------------------------------------
/src/components/IntegrationImage/index.js:
--------------------------------------------------------------------------------
1 | export { default } from "./IntegrationImage";
2 |
--------------------------------------------------------------------------------
/src/components/PrivateRoute.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Route, Redirect } from "react-router-dom";
3 |
4 | import { useAuth } from "../providers/AuthProvider";
5 |
6 | import CircularProgress from "@material-ui/core/CircularProgress";
7 | import { Box } from "@material-ui/core";
8 |
9 | export default function PrivateRoute({ children, ...rest }) {
10 | const { isAuthenticatingGoogle, isAllConnected } = useAuth();
11 |
12 | if (isAuthenticatingGoogle) {
13 | return (
14 |
15 |
16 |
17 | );
18 | }
19 |
20 | return (
21 |
24 | isAllConnected ? (
25 | children
26 | ) : (
27 |
33 | )
34 | }
35 | />
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/src/components/ProjectCombobox/ProjectCombobox.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 |
3 | import TextField from "@material-ui/core/TextField";
4 | import Autocomplete from "@material-ui/lab/Autocomplete";
5 | import CircularProgress from "@material-ui/core/CircularProgress";
6 |
7 | import { fetchProjects } from "../../services/zeplin";
8 |
9 | export default function ProjectCombobox({ onProjectSelect }) {
10 | const [open, setOpen] = useState(false);
11 | const [options, setOptions] = useState();
12 | const loading = open && !options;
13 |
14 | const onOpen = async () => {
15 | setOpen(true);
16 |
17 | if (options) {
18 | return;
19 | }
20 |
21 | const projects = await fetchProjects();
22 |
23 | setOptions(
24 | projects.map((project) => ({
25 | name: project.name,
26 | value: project,
27 | }))
28 | );
29 | };
30 |
31 | const onClose = () => {
32 | setOpen(false);
33 | };
34 |
35 | const onChange = (event, value) => {
36 | if (value) {
37 | onProjectSelect(value.value);
38 | }
39 | };
40 |
41 | return (
42 | option.value === value.value}
48 | getOptionLabel={(option) => option.name}
49 | options={options || []}
50 | loading={loading}
51 | noOptionsText="No projects in this account"
52 | renderInput={(params) => (
53 |
61 | {loading ? (
62 |
63 | ) : null}
64 | {params.InputProps.endAdornment}
65 | >
66 | ),
67 | }}
68 | />
69 | )}
70 | />
71 | );
72 | }
73 |
--------------------------------------------------------------------------------
/src/components/ProjectCombobox/index.js:
--------------------------------------------------------------------------------
1 | export { default } from "./ProjectCombobox";
2 |
--------------------------------------------------------------------------------
/src/components/ZeplinConnectCard/ZeplinConnectCard.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from "react";
2 | import { useLocation, useHistory } from "react-router-dom";
3 |
4 | import { useStore } from "../../providers/StoreProvider";
5 | import { useAuth } from "../../providers/AuthProvider";
6 | import {
7 | fetchAccessToken,
8 | authorize as authorizeZeplin,
9 | } from "../../services/zeplin";
10 |
11 | import ConnectCard from "../ConnectCard";
12 |
13 | function ZeplinConnectCard() {
14 | const { search } = useLocation();
15 | const history = useHistory();
16 | const { actions, selectors } = useStore();
17 | const { isZeplinConnected, connectZeplin, disconnectZeplin } = useAuth();
18 | const [authenticating, setAuthenticating] = useState(false);
19 |
20 | const code = new URLSearchParams(search).get("code");
21 | const zeplinUser = selectors.zeplinUser();
22 |
23 | useEffect(() => {
24 | async function getZeplinToken(code) {
25 | try {
26 | setAuthenticating(true);
27 | const { access_token } = await fetchAccessToken(code);
28 |
29 | if (access_token) {
30 | connectZeplin(access_token);
31 |
32 | setAuthenticating(false);
33 |
34 | history.replace("/");
35 | }
36 | } catch {
37 | // noop
38 | }
39 | }
40 |
41 | if (code) {
42 | getZeplinToken(code);
43 | }
44 | }, []);
45 |
46 | useEffect(() => {
47 | if (isZeplinConnected && !zeplinUser) {
48 | actions.getZeplinUser();
49 | }
50 | }, [isZeplinConnected, zeplinUser]);
51 |
52 | return (
53 | }
61 | authenticating={authenticating}
62 | />
63 | );
64 | }
65 |
66 | export default ZeplinConnectCard;
67 |
--------------------------------------------------------------------------------
/src/components/ZeplinConnectCard/index.js:
--------------------------------------------------------------------------------
1 | export { default } from "./ZeplinConnectCard";
2 |
--------------------------------------------------------------------------------
/src/constants/index.js:
--------------------------------------------------------------------------------
1 | export const ZEPLIN_TOKEN_STORAGE_KEY = "zeplinToken";
2 | export const APP_URL = process.env.REACT_APP_APP_URL;
3 | export const GOOGLE_CLIENT_ID = process.env.REACT_APP_GOOGLE_CLIENT_ID;
4 | export const GOOGLE_API_KEY = process.env.REACT_APP_API_KEY;
5 | export const ZEPLIN_CLIENT_ID = process.env.REACT_APP_ZEPLIN_CLIENT_ID;
6 | export const ZEPLIN_CLIENT_SECRET = process.env.REACT_APP_ZEPLIN_CLIENT_SECRET;
7 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | font-family: 'Roboto', 'Oxygen',
4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
5 | sans-serif;
6 | -webkit-font-smoothing: antialiased;
7 | -moz-osx-font-smoothing: grayscale;
8 | background-color: #f5f5f5;
9 | }
10 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom";
3 |
4 | import App from "./App";
5 |
6 | import "./index.css";
7 |
8 | import "typeface-roboto";
9 |
10 | ReactDOM.render(
11 |
12 |
13 | ,
14 | document.getElementById("root")
15 | );
16 |
--------------------------------------------------------------------------------
/src/layouts/Main.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import { Container } from "@material-ui/core";
4 |
5 | import Header from "../components/Header";
6 |
7 | function Main({ children, maxWidth = "sm" }) {
8 | return (
9 |
10 |
11 |
12 | {children}
13 |
14 | );
15 | }
16 |
17 | export default Main;
18 |
--------------------------------------------------------------------------------
/src/pages/Connect.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useHistory } from "react-router-dom";
3 |
4 | import { Button } from "@material-ui/core";
5 | import CreateIcon from "@material-ui/icons/Slideshow";
6 |
7 | import { useAuth } from "../providers/AuthProvider";
8 |
9 | import Main from "../layouts/Main";
10 |
11 | import ZeplinConnectCard from "../components/ZeplinConnectCard";
12 | import GoogleConnectCard from "../components/GoogleConnectCard";
13 |
14 | function Connect() {
15 | const history = useHistory();
16 | const { isAllConnected } = useAuth();
17 |
18 | const onCreate = () => {
19 | history.push("/create");
20 | };
21 |
22 | return (
23 |
24 |
25 |
26 |
27 | {isAllConnected && (
28 | }
34 | fullWidth
35 | disableElevation
36 | >
37 | Create a presentation
38 |
39 | )}
40 |
41 | );
42 | }
43 |
44 | export default Connect;
45 |
--------------------------------------------------------------------------------
/src/pages/Create.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import { useHistory } from "react-router-dom";
3 |
4 | import ArrowBack from "@material-ui/icons/ArrowBack";
5 | import { green } from "@material-ui/core/colors";
6 | import { Alert, AlertTitle } from "@material-ui/lab";
7 | import {
8 | Paper,
9 | Box,
10 | Link,
11 | Typography,
12 | Button,
13 | CircularProgress,
14 | makeStyles,
15 | } from "@material-ui/core";
16 |
17 | import Main from "../layouts/Main";
18 | import ProjectCombobox from "../components/ProjectCombobox";
19 |
20 | import { createPresentationFromProject } from "../services/google";
21 |
22 | const useStyles = makeStyles((theme) => ({
23 | buttonSuccess: {
24 | backgroundColor: green[500],
25 | "&:hover": {
26 | backgroundColor: green[700],
27 | },
28 | color: "white",
29 | },
30 | }));
31 |
32 | export default function Create() {
33 | const classes = useStyles();
34 | const history = useHistory();
35 | const [selectedProject, setSelectedProject] = useState();
36 | const [creating, setCreating] = useState();
37 | const [error, setError] = useState();
38 | const [createdPresentation, setCreatedPresentation] = useState();
39 |
40 | const onBack = (e) => {
41 | e.preventDefault();
42 |
43 | history.replace("/");
44 | };
45 |
46 | const onCreate = async () => {
47 | setCreating(true);
48 |
49 | const response = await createPresentationFromProject(selectedProject);
50 |
51 | if (response.error) {
52 | setError(response.error);
53 | setCreating(false);
54 | return;
55 | }
56 |
57 | setCreatedPresentation({
58 | url: response,
59 | project: selectedProject,
60 | });
61 | setError(null);
62 | setCreating(false);
63 | };
64 |
65 | return (
66 |
67 |
68 |
69 |
70 |
71 |
72 | Create a presentation from a Zeplin project
73 |
74 |
75 |
76 |
77 |
78 |
79 |
89 | }
90 | >
91 | Create
92 |
93 |
94 |
95 |
96 |
97 |
98 | {!creating && createdPresentation && (
99 |
100 |
101 |
102 | Your presentation is created successfully!
103 |
104 |
105 | {createdPresentation.project.name} is created
106 | under your Google Slides account. Click below to check it out!
107 |
108 |
109 |
110 |
122 |
123 |
124 |
125 | )}
126 |
127 | {error && (
128 |
129 | Error occurred!
130 | {error.message}
131 |
132 | )}
133 |
134 |
135 |
140 |
141 | Change accounts
142 |
143 |
144 |
145 | );
146 | }
147 |
--------------------------------------------------------------------------------
/src/pages/Home.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useHistory } from "react-router-dom";
3 |
4 | import { Typography, Box, Button } from "@material-ui/core";
5 |
6 | import Main from "../layouts/Main";
7 | import HomeCard from "../components/HomeCard";
8 | import Footer from "../components/Footer";
9 |
10 | export default function Home() {
11 | const history = useHistory();
12 |
13 | const onClick = () => {
14 | history.push("/");
15 | };
16 |
17 | return (
18 |
19 |
20 |
27 | Zeplin Slides allows you to create Google Slide
28 | presentations from screens of your Zeplin projects in 3 simple steps.
29 |
30 |
31 | }
34 | />
35 |
36 | }
39 | reverse
40 | />
41 |
42 | }
45 | />
46 |
47 | }
50 | reverse
51 | disableImageBorder
52 | />
53 |
54 |
55 |
65 |
66 |
67 |
68 |
69 | );
70 | }
71 |
--------------------------------------------------------------------------------
/src/providers/AuthProvider.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from "react";
2 | import { onClientLoad } from "../services/google";
3 | import { ZEPLIN_TOKEN_STORAGE_KEY } from "../constants";
4 |
5 | const AuthContext = React.createContext();
6 |
7 | const storedZeplinToken = localStorage.getItem(ZEPLIN_TOKEN_STORAGE_KEY);
8 |
9 | function AuthProvider({ children }) {
10 | const [isAuthenticatingGoogle, setGoogleAuthenticating] = useState(true);
11 | const [zeplinToken, setZeplinToken] = useState(storedZeplinToken);
12 | const [googleUser, setGoogleUser] = useState(false);
13 |
14 | useEffect(() => {
15 | onClientLoad(user => {
16 | setGoogleUser(user);
17 |
18 | setGoogleAuthenticating(false);
19 | });
20 | }, []);
21 |
22 | const connectZeplin = (token) => {
23 | localStorage.setItem(ZEPLIN_TOKEN_STORAGE_KEY, token);
24 |
25 | setZeplinToken(token);
26 | };
27 |
28 | const disconnectZeplin = () => {
29 | localStorage.removeItem(ZEPLIN_TOKEN_STORAGE_KEY);
30 |
31 | setZeplinToken(null);
32 | };
33 |
34 | const isZeplinConnected = !!zeplinToken;
35 | const isGoogleConnected = !!googleUser;
36 |
37 | const isAllConnected = isZeplinConnected && !!isGoogleConnected;
38 |
39 | const zeplinContextValues = {
40 | zeplinToken,
41 | connectZeplin,
42 | disconnectZeplin,
43 | isZeplinConnected,
44 | };
45 |
46 | const googleContextValues = {
47 | googleUser,
48 | setGoogleUser,
49 | isGoogleConnected,
50 | isAuthenticatingGoogle,
51 | };
52 |
53 | return (
54 |
57 | {children}
58 |
59 | );
60 | }
61 |
62 | const useAuth = () => React.useContext(AuthContext);
63 |
64 | export { AuthProvider, useAuth };
65 |
--------------------------------------------------------------------------------
/src/providers/StoreProvider/StoreProvider.jsx:
--------------------------------------------------------------------------------
1 | import React, { createContext, useContext, useReducer } from "react";
2 |
3 | import reducer from "./reducer";
4 | import { getActions } from "./actions";
5 | import { getSelectors } from "./selectors";
6 |
7 | const initialState = {
8 | zeplinUser: null
9 | };
10 |
11 | const StateContext = createContext();
12 |
13 | const StoreProvider = ({ children }) => {
14 | const [state, dispatch] = useReducer(reducer, initialState);
15 |
16 | const selectors = getSelectors(state);
17 | const actions = getActions(dispatch);
18 |
19 | return (
20 |
24 | );
25 | };
26 |
27 | const useStore = () => useContext(StateContext);
28 |
29 | export { StoreProvider, useStore };
30 |
--------------------------------------------------------------------------------
/src/providers/StoreProvider/actions.js:
--------------------------------------------------------------------------------
1 | import ACTION_TYPES from "./types";
2 |
3 | import * as zeplinService from "../../services/zeplin";
4 |
5 | const getActions = dispatch => ({
6 | async getZeplinUser() {
7 | const zeplinUser = await zeplinService.fetchCurrentUser();
8 |
9 | dispatch({
10 | type: ACTION_TYPES.GET_ZEPLIN_USER,
11 | zeplinUser
12 | });
13 | },
14 | });
15 |
16 | export { getActions };
17 |
--------------------------------------------------------------------------------
/src/providers/StoreProvider/index.js:
--------------------------------------------------------------------------------
1 | import { StoreProvider, useStore } from "./StoreProvider";
2 |
3 | export { StoreProvider, useStore };
4 |
--------------------------------------------------------------------------------
/src/providers/StoreProvider/reducer.js:
--------------------------------------------------------------------------------
1 | import ACTION_TYPES from "./types";
2 |
3 | const reducers = {
4 | loadZeplinUser(state, { zeplinUser }) {
5 | return {
6 | ...state,
7 | zeplinUser
8 | };
9 | },
10 | };
11 |
12 | const reducer = (state, action) => {
13 | switch (action.type) {
14 | case ACTION_TYPES.GET_ZEPLIN_USER:
15 | return reducers.loadZeplinUser(state, action);
16 | default:
17 | return state;
18 | }
19 | };
20 |
21 | export default reducer;
22 |
--------------------------------------------------------------------------------
/src/providers/StoreProvider/selectors.js:
--------------------------------------------------------------------------------
1 | const getSelectors = state => ({
2 | zeplinUser() {
3 | return state.zeplinUser;
4 | },
5 | });
6 |
7 | export { getSelectors };
8 |
--------------------------------------------------------------------------------
/src/providers/StoreProvider/types.js:
--------------------------------------------------------------------------------
1 | export default {
2 | GET_ZEPLIN_USER: "getZeplinUser",
3 | };
4 |
--------------------------------------------------------------------------------
/src/services/google.js:
--------------------------------------------------------------------------------
1 | import * as moment from "moment";
2 |
3 | import { APP_URL, GOOGLE_API_KEY, GOOGLE_CLIENT_ID } from "../constants";
4 | import { fetchProjectScreensGroupedBySection } from "./zeplin";
5 | import { getScreenImageProperties } from "../utils/image";
6 |
7 | const DISCOVERY_DOCS = [
8 | "https://slides.googleapis.com/$discovery/rest?version=v1",
9 | ];
10 | const SCOPES = "https://www.googleapis.com/auth/presentations";
11 |
12 | const gapi = window.gapi;
13 |
14 | export function onClientLoad(onUpdateUserProfile) {
15 | gapi.load("client:auth2", () => init(onUpdateUserProfile));
16 | }
17 |
18 | function getCurrentUserProfile(user) {
19 | if (user.isSignedIn()) {
20 | return {
21 | email: user.getBasicProfile().getEmail(),
22 | };
23 | }
24 |
25 | return null;
26 | }
27 |
28 | function init(onUpdateUserProfile) {
29 | gapi.client
30 | .init({
31 | apiKey: GOOGLE_API_KEY,
32 | clientId: GOOGLE_CLIENT_ID,
33 | discoveryDocs: DISCOVERY_DOCS,
34 | scope: SCOPES,
35 | })
36 | .then(
37 | () => {
38 | const currentGoogleUser = gapi.auth2.getAuthInstance().currentUser;
39 | const currentUser = currentGoogleUser.get();
40 | const profile = getCurrentUserProfile(currentUser);
41 | onUpdateUserProfile(profile);
42 |
43 | currentGoogleUser.listen((currentUser) => {
44 | const profile = getCurrentUserProfile(currentUser);
45 | onUpdateUserProfile(profile);
46 | });
47 | },
48 | (error) => {
49 | console.log(JSON.stringify(error, null, 2));
50 | }
51 | );
52 | }
53 |
54 | export function onSignInClick() {
55 | gapi.auth2.getAuthInstance().signIn();
56 | }
57 |
58 | export function onSignOutClick() {
59 | gapi.auth2.getAuthInstance().signOut();
60 | }
61 |
62 | function getPresentationUrl(presentationId) {
63 | return `https://docs.google.com/presentation/d/${presentationId}`;
64 | }
65 |
66 | function generateSlideRequests({ slideId, title, subtitle, layouts }) {
67 | const titleLayout = layouts.find(
68 | (layout) => layout.layoutProperties.name === "TITLE"
69 | );
70 |
71 | if (!titleLayout) {
72 | return []; // TODO: return text box shape if layout does not exist
73 | }
74 |
75 | const pageTitleId = `page_title_id_${slideId}`;
76 | const pageSubtitleId = `page_subtitle_id_${slideId}`;
77 | const pageSlideNumberId = `page_slide_number_id_${slideId}`;
78 |
79 | return [
80 | {
81 | createSlide: {
82 | objectId: slideId,
83 | slideLayoutReference: {
84 | predefinedLayout: "TITLE",
85 | },
86 | placeholderIdMappings: [
87 | {
88 | layoutPlaceholder: {
89 | type: "CENTERED_TITLE",
90 | },
91 | objectId: pageTitleId,
92 | },
93 | {
94 | layoutPlaceholder: {
95 | type: "SUBTITLE",
96 | },
97 | objectId: pageSubtitleId,
98 | },
99 | {
100 | layoutPlaceholder: {
101 | type: "SLIDE_NUMBER",
102 | },
103 | objectId: pageSlideNumberId,
104 | },
105 | ],
106 | },
107 | },
108 | {
109 | insertText: {
110 | objectId: pageTitleId,
111 | text: title,
112 | },
113 | },
114 | {
115 | insertText: {
116 | objectId: pageSubtitleId,
117 | text: subtitle,
118 | },
119 | },
120 | ];
121 | }
122 |
123 | function generateScreenRequests({ screen, pageSize }) {
124 | const {
125 | id: screenId,
126 | name: screenName,
127 | image: {
128 | original_url: screenUrl,
129 | width: screenWidth,
130 | height: screenHeight,
131 | },
132 | } = screen;
133 |
134 | const {
135 | width: { magnitude: pageWidth, unit: pageDimensionsUnit },
136 | height: { magnitude: pageHeight },
137 | } = pageSize;
138 |
139 | const pageId = `page_id_${screenId}`;
140 | const titleId = `${pageId}_title`;
141 | const imageId = `${pageId}_image`;
142 | return [
143 | // Add a slide for screen
144 | {
145 | createSlide: {
146 | objectId: pageId,
147 | },
148 | },
149 | // Add screen image
150 | {
151 | createImage: {
152 | objectId: imageId,
153 | url: screenUrl,
154 | elementProperties: getScreenImageProperties({
155 | pageId,
156 | width: screenWidth,
157 | height: screenHeight,
158 | pageSize,
159 | }),
160 | },
161 | },
162 | // Add screen name
163 | {
164 | createShape: {
165 | objectId: titleId,
166 | shapeType: "TEXT_BOX",
167 | elementProperties: {
168 | pageObjectId: pageId,
169 | size: {
170 | height: {
171 | magnitude: pageHeight / 12,
172 | unit: pageDimensionsUnit,
173 | },
174 | width: {
175 | magnitude: pageWidth,
176 | unit: pageDimensionsUnit,
177 | },
178 | },
179 | },
180 | },
181 | },
182 | {
183 | insertText: {
184 | objectId: titleId,
185 | text: screenName,
186 | },
187 | },
188 | ];
189 | }
190 |
191 | function generateSectionRequests({ section, index, pageSize, layouts }) {
192 | const {
193 | id: sectionId,
194 | name: sectionName,
195 | description: sectionDescription,
196 | screens: sectionScreens,
197 | } = section;
198 |
199 | let sectionRequests = [];
200 |
201 | // If screens do not have a section, do not add title page slide for them
202 | if (section.id !== "default") {
203 | sectionRequests = generateSlideRequests({
204 | slideId: sectionId,
205 | title: sectionName,
206 | subtitle: sectionDescription,
207 | layouts,
208 | });
209 | }
210 |
211 | const screenRequests = sectionScreens.map((screen) =>
212 | generateScreenRequests({ screen, pageSize })
213 | );
214 |
215 | return sectionRequests.concat(screenRequests);
216 | }
217 |
218 | function generateLastSlideRequests({
219 | slideId,
220 | title,
221 | subtitle,
222 | layouts,
223 | pageSize,
224 | }) {
225 | const requests = generateSlideRequests({ slideId, title, subtitle, layouts });
226 |
227 | const {
228 | width: { magnitude: pageWidth, unit: pageDimensionsUnit },
229 | height: { magnitude: pageHeight },
230 | } = pageSize;
231 |
232 | const textId = `${slideId}_text`;
233 | return requests.concat([
234 | // Add screen name
235 | {
236 | createShape: {
237 | objectId: textId,
238 | shapeType: "TEXT_BOX",
239 | elementProperties: {
240 | pageObjectId: slideId,
241 | size: {
242 | height: {
243 | magnitude: pageHeight / 12,
244 | unit: pageDimensionsUnit,
245 | },
246 | width: {
247 | magnitude: pageWidth,
248 | unit: pageDimensionsUnit,
249 | },
250 | },
251 | transform: {
252 | scaleX: 1,
253 | scaleY: 1,
254 | translateY: (pageHeight / 12) * 11,
255 | unit: pageDimensionsUnit,
256 | },
257 | },
258 | },
259 | },
260 | {
261 | insertText: {
262 | objectId: textId,
263 | text: "Crafted with Zeplin Slides",
264 | },
265 | },
266 | {
267 | updateParagraphStyle: {
268 | objectId: textId,
269 | textRange: { type: "ALL" },
270 | style: { alignment: "CENTER" },
271 | fields: "alignment",
272 | },
273 | },
274 | {
275 | updateTextStyle: {
276 | objectId: textId,
277 | textRange: {
278 | type: "FIXED_RANGE",
279 | startIndex: 13,
280 | endIndex: 27,
281 | },
282 | style: {
283 | link: {
284 | url: APP_URL,
285 | },
286 | },
287 | fields: "link",
288 | },
289 | },
290 | ]);
291 | }
292 |
293 | export async function createPresentationFromProject(project) {
294 | const {
295 | id: projectId,
296 | name: projectName,
297 | updated: projectUpdateTimestamp,
298 | } = project;
299 |
300 | try {
301 | // Create presentation
302 | const createResponse = await gapi.client.slides.presentations.create({
303 | title: projectName,
304 | });
305 |
306 | console.log("Presentation created: ", createResponse.result);
307 |
308 | const {
309 | result: {
310 | presentationId,
311 | pageSize,
312 | layouts,
313 | slides: [{ objectId: firstSlideId } = {}],
314 | },
315 | } = createResponse;
316 |
317 | // Delete first slide
318 | const deleteRequest = {
319 | deleteObject: {
320 | objectId: firstSlideId,
321 | },
322 | };
323 |
324 | // Generate first slide for project
325 | const projectSlideRequests = generateSlideRequests({
326 | slideId: projectId,
327 | title: projectName,
328 | subtitle: moment(projectUpdateTimestamp * 1000).format("ll"),
329 | layouts,
330 | });
331 |
332 | // Generate section title pages & section screens slides
333 | const projectSections = await fetchProjectScreensGroupedBySection(
334 | projectId
335 | );
336 | const sectionsSlideRequests = projectSections.flatMap((section, index) =>
337 | generateSectionRequests({ section, index, pageSize, layouts })
338 | );
339 |
340 | // Generate last slide
341 | const lastSlideRequests = generateLastSlideRequests({
342 | slideId: "last_slide",
343 | title: "~Fin~",
344 | layouts,
345 | pageSize,
346 | });
347 |
348 | const requests = [
349 | firstSlideId ? deleteRequest : null,
350 | projectSlideRequests,
351 | sectionsSlideRequests,
352 | lastSlideRequests,
353 | ].flat();
354 |
355 | const updateResponse = await gapi.client.slides.presentations.batchUpdate({
356 | presentationId,
357 | requests,
358 | });
359 |
360 | console.log("Updated presentation:", updateResponse.result);
361 |
362 | return getPresentationUrl(presentationId);
363 | } catch (errorResponse) {
364 | return errorResponse.result;
365 | }
366 | }
367 |
--------------------------------------------------------------------------------
/src/services/zeplin.js:
--------------------------------------------------------------------------------
1 | import http, { handleResponse } from "../utils/http";
2 | import { APP_URL, ZEPLIN_CLIENT_ID, ZEPLIN_CLIENT_SECRET } from "../constants";
3 |
4 | const ZEPLIN_API_URL = "https://api.zeplin.dev/v1";
5 |
6 | export function authorize() {
7 | window.location = `${ZEPLIN_API_URL}/oauth/authorize?client_id=${ZEPLIN_CLIENT_ID}&redirect_uri=${APP_URL}&response_type=code`;
8 | }
9 |
10 | export function fetchAccessToken(code) {
11 | const params = {
12 | grant_type: "authorization_code",
13 | client_id: ZEPLIN_CLIENT_ID,
14 | client_secret: ZEPLIN_CLIENT_SECRET,
15 | redirect_uri: APP_URL,
16 | code,
17 | };
18 |
19 | return http
20 | .post(`${ZEPLIN_API_URL}/oauth/token`, params)
21 | .then(handleResponse);
22 | }
23 |
24 | export function fetchCurrentUser() {
25 | return http.get(`${ZEPLIN_API_URL}/users/me`).then(handleResponse);
26 | }
27 |
28 | export async function fetchProjects() {
29 | return http.get(`${ZEPLIN_API_URL}/projects?limit=100`).then(handleResponse);
30 | }
31 |
32 | function fetchProjectScreens(pid) {
33 | return http
34 | .get(`${ZEPLIN_API_URL}/projects/${pid}/screens?sort=section`)
35 | .then(handleResponse);
36 | }
37 |
38 | function fetchProjectScreenSections(pid) {
39 | return http
40 | .get(`${ZEPLIN_API_URL}/projects/${pid}/screen_sections`)
41 | .then(handleResponse);
42 | }
43 |
44 | const DEFAULT_SECTION = {
45 | id: "default",
46 | };
47 |
48 | export async function fetchProjectScreensGroupedBySection(pid) {
49 | return Promise.all([
50 | fetchProjectScreens(pid),
51 | fetchProjectScreenSections(pid),
52 | ])
53 | .then(([screens, sections]) =>
54 | [DEFAULT_SECTION].concat(sections).map((section) => ({
55 | ...section,
56 | screens: screens.filter((screen) => {
57 | if (screen.section) {
58 | return screen.section.id === section.id;
59 | }
60 |
61 | return section.id === "default";
62 | }),
63 | }))
64 | )
65 | .catch((e) => {
66 | console.log(e);
67 | return [];
68 | });
69 | }
70 |
--------------------------------------------------------------------------------
/src/setupTests.js:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import '@testing-library/jest-dom/extend-expect';
6 |
--------------------------------------------------------------------------------
/src/utils/http.js:
--------------------------------------------------------------------------------
1 | import { ZEPLIN_TOKEN_STORAGE_KEY } from "../constants";
2 |
3 | function getHeaders() {
4 | const headers = new Headers();
5 | headers.set("Content-Type", "application/json");
6 |
7 | const zeplinToken = localStorage.getItem("zeplinToken");
8 | if (zeplinToken) {
9 | headers.set("Authorization", `Bearer ${zeplinToken}`);
10 | }
11 |
12 | return headers;
13 | }
14 |
15 | export function handleResponse(response) {
16 | return response.json().then((resJson) => {
17 | if (response.ok) {
18 | return resJson;
19 | }
20 |
21 | if (
22 | resJson.message === "token_expired" ||
23 | resJson.message === "invalid_token"
24 | ) {
25 | localStorage.removeItem(ZEPLIN_TOKEN_STORAGE_KEY);
26 |
27 | window.location = "/";
28 | }
29 |
30 | // TODO: show modal for errors
31 | throw new Error(resJson.message);
32 | });
33 | }
34 |
35 | const http = {
36 | /**
37 | * Sends a get request with Zeplin headers.
38 | * @param {String} url
39 | * @param {Object} options additional fetch options
40 | */
41 | get(url, options = {}) {
42 | return fetch(url, { headers: getHeaders(), ...options });
43 | },
44 |
45 | /**
46 | * Sends a post request with Zeplin headers.
47 | * @param {String} url
48 | * @param {Object} body
49 | */
50 | post(url, body) {
51 | const args = {
52 | headers: getHeaders(),
53 | method: "POST",
54 | };
55 |
56 | if (body) {
57 | Object.assign(args, { body: JSON.stringify(body) });
58 | }
59 |
60 | return fetch(url, args);
61 | },
62 |
63 | /**
64 | * Sends a put request with Zeplin headers
65 | * @param {String} url
66 | * @param {Object} body
67 | */
68 | put(url, body) {
69 | const args = {
70 | headers: getHeaders(),
71 | method: "PUT",
72 | };
73 |
74 | if (body) {
75 | Object.assign(args, { body: JSON.stringify(body) });
76 | }
77 |
78 | return fetch(url, args);
79 | },
80 |
81 | /**
82 | * Sends a delete request with Zeplin headers
83 | * @param {String} url
84 | * @param {Object} body
85 | */
86 | delete(url, body) {
87 | const args = {
88 | headers: getHeaders(),
89 | method: "DELETE",
90 | };
91 |
92 | if (body) {
93 | Object.assign(args, { body: JSON.stringify(body) });
94 | }
95 |
96 | return fetch(url, args);
97 | },
98 | };
99 |
100 | export default http;
101 |
--------------------------------------------------------------------------------
/src/utils/image.js:
--------------------------------------------------------------------------------
1 | function getAspectRatio(width, height) {
2 | return width / height;
3 | }
4 |
5 | function getDimensionsGivenWidth(width, ratio) {
6 | return {
7 | width,
8 | height: Math.round(width / ratio),
9 | };
10 | }
11 |
12 | function getDimensionsGivenHeight(height, ratio) {
13 | return {
14 | width: Math.round(height * ratio),
15 | height,
16 | };
17 | }
18 |
19 | export function getFittedDimensions({ width, height, pageWidth, pageHeight }) {
20 | const aspectRatio = getAspectRatio(width, height);
21 | const pageAspectRatio = getAspectRatio(pageWidth, pageHeight);
22 |
23 | if (pageAspectRatio > aspectRatio) {
24 | return getDimensionsGivenHeight(pageHeight, aspectRatio);
25 | }
26 |
27 | return getDimensionsGivenWidth(pageWidth, aspectRatio);
28 | }
29 |
30 | function getTranslateValueToCenter(value, pageValue) {
31 | return Math.round((pageValue - value) / 2);
32 | }
33 |
34 | export function getTranslateValuesForFittedDimensions({
35 | width,
36 | height,
37 | pageWidth,
38 | pageHeight,
39 | }) {
40 | const aspectRatio = getAspectRatio(width, height);
41 | const pageAspectRatio = getAspectRatio(pageWidth, pageHeight);
42 |
43 | if (pageAspectRatio > aspectRatio) {
44 | return {
45 | translateX: getTranslateValueToCenter(width, pageWidth),
46 | };
47 | }
48 |
49 | return {
50 | translateY: getTranslateValueToCenter(height, pageHeight),
51 | };
52 | }
53 |
54 | export function getScreenImageProperties({ pageId, width, height, pageSize }) {
55 | const {
56 | width: { magnitude: pageWidth, unit },
57 | height: { magnitude: pageHeight },
58 | } = pageSize;
59 |
60 | const fittedDimensions = getFittedDimensions({
61 | width,
62 | height,
63 | pageWidth,
64 | pageHeight,
65 | });
66 | const translateValues = getTranslateValuesForFittedDimensions({
67 | ...fittedDimensions,
68 | pageWidth,
69 | pageHeight,
70 | });
71 |
72 | return {
73 | pageObjectId: pageId,
74 | size: {
75 | height: {
76 | magnitude: fittedDimensions.height,
77 | unit,
78 | },
79 | width: {
80 | magnitude: fittedDimensions.width,
81 | unit,
82 | },
83 | },
84 | transform: {
85 | scaleX: 1,
86 | scaleY: 1,
87 | ...translateValues,
88 | unit,
89 | },
90 | };
91 | }
92 |
--------------------------------------------------------------------------------
/src/utils/image.test.js:
--------------------------------------------------------------------------------
1 | import { getFittedDimensions, getTranslateValuesForFittedDimensions } from "./image";
2 |
3 | describe("Image: getFittedDimensions", () => {
4 | it("returns dimensions that fit to page given small image", () => {
5 | const width = 300;
6 | const height = 100;
7 | const pageWidth = 600;
8 | const pageHeight = 400;
9 |
10 | const expected = {
11 | width: 600,
12 | height: 200,
13 | };
14 |
15 | const actual = getFittedDimensions({
16 | width,
17 | height,
18 | pageWidth,
19 | pageHeight,
20 | });
21 |
22 | expect(actual).toEqual(expected);
23 | });
24 |
25 | it("returns dimensions that fit to page given overflowing image", () => {
26 | const width = 900;
27 | const height = 720;
28 | const pageWidth = 600;
29 | const pageHeight = 400;
30 |
31 | const expected = {
32 | width: 500,
33 | height: 400,
34 | };
35 |
36 | const actual = getFittedDimensions({
37 | width,
38 | height,
39 | pageWidth,
40 | pageHeight,
41 | });
42 |
43 | expect(actual).toEqual(expected);
44 | });
45 |
46 | it("returns dimensions that fit to page given image with <1 aspect ratio", () => {
47 | const width = 400;
48 | const height = 500;
49 | const pageWidth = 600;
50 | const pageHeight = 400;
51 |
52 | const expected = {
53 | width: 320,
54 | height: 400,
55 | };
56 |
57 | const actual = getFittedDimensions({
58 | width,
59 | height,
60 | pageWidth,
61 | pageHeight,
62 | });
63 |
64 | expect(actual).toEqual(expected);
65 | });
66 |
67 | it("returns dimensions that fit to page given overflowing image with >1 aspect ratio", () => {
68 | const width = 500;
69 | const height = 900;
70 | const pageWidth = 600;
71 | const pageHeight = 400;
72 |
73 | const expected = {
74 | width: 222,
75 | height: 400,
76 | };
77 |
78 | const actual = getFittedDimensions({
79 | width,
80 | height,
81 | pageWidth,
82 | pageHeight,
83 | });
84 |
85 | expect(actual).toEqual(expected);
86 | });
87 | });
88 |
89 | describe("Image: getTranslateValuesForFittedDimensions", () => {
90 | it("returns translate values given small image", () => {
91 | const width = 300;
92 | const height = 100;
93 | const pageWidth = 600;
94 | const pageHeight = 400;
95 |
96 | const fittedDimensions = getFittedDimensions({
97 | width,
98 | height,
99 | pageWidth,
100 | pageHeight,
101 | });
102 |
103 | const expected = {
104 | translateY: 100,
105 | };
106 |
107 | const actual = getTranslateValuesForFittedDimensions({
108 | ...fittedDimensions,
109 | pageWidth,
110 | pageHeight,
111 | });
112 |
113 | expect(actual).toEqual(expected);
114 | });
115 | });
116 |
117 | describe("Image: getTranslateValuesForFittedDimensions", () => {
118 | it("returns translate values given big image with <1 aspect ratio", () => {
119 | const width = 400;
120 | const height = 1600;
121 | const pageWidth = 600;
122 | const pageHeight = 400;
123 |
124 | const fittedDimensions = getFittedDimensions({
125 | width,
126 | height,
127 | pageWidth,
128 | pageHeight,
129 | });
130 |
131 | const expected = {
132 | translateX: 250,
133 | };
134 |
135 | const actual = getTranslateValuesForFittedDimensions({
136 | ...fittedDimensions,
137 | pageWidth,
138 | pageHeight,
139 | });
140 |
141 | expect(actual).toEqual(expected);
142 | });
143 | });
144 |
--------------------------------------------------------------------------------