├── .babelrc ├── .gitignore ├── LICENSE ├── README.md ├── assets ├── demo.gif ├── screenshot.jpg └── screenshot2.jpg ├── cosmos.config.js ├── demo └── simple │ ├── .babelrc │ ├── index.css │ ├── index.html │ ├── index.js │ ├── package.json │ └── webpack.config.js ├── images ├── Fitness App.sketch ├── bg.png ├── folder.png └── ui-video-simple-john-hansen.sketch ├── index.html ├── main.js ├── package.json ├── src ├── __tests__ │ ├── Style.spec.js │ └── utils.spec.js ├── components │ ├── Document │ │ ├── Document.js │ │ ├── Document.styl │ │ ├── LayerIndicator.js │ │ ├── LayerInfo.js │ │ ├── LayerSelector.js │ │ ├── LayerSelector │ │ │ └── __fixtures__ │ │ │ │ └── default.js │ │ ├── PageSelector.js │ │ ├── PageSelector │ │ │ └── __fixtures__ │ │ │ │ └── default.js │ │ ├── __fixtures__ │ │ │ ├── Fitness App.sketch │ │ │ ├── document.js │ │ │ └── ui-video-simple-john-hansen.sketch │ │ ├── bg.png │ │ └── folder.png │ ├── Layer │ │ ├── Artboard.js │ │ ├── Bitmap.js │ │ ├── Group.js │ │ ├── Layer.js │ │ ├── Page.js │ │ ├── ShapeGroup.js │ │ ├── Symbol.js │ │ ├── Text.js │ │ └── __fixtures__ │ │ │ └── layer.js │ └── Site │ │ ├── App.js │ │ ├── App │ │ └── __fixtures__ │ │ │ └── default.js │ │ ├── Loading.js │ │ ├── Loading │ │ └── __fixtures__ │ │ │ └── default.js │ │ └── index.css ├── index.js ├── models │ └── index.js └── utils │ ├── JSX.js │ ├── bplist-parser.js │ ├── index.js │ └── pretty-bytes.js ├── webpack.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "es2015", 4 | "react", 5 | "stage-0" 6 | ], 7 | "plugins": [ 8 | ] 9 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | .idea 39 | playground/ 40 | .DS_Store -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 asmn 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sketch-react 2 | 3 | [https://zjuasmn.github.io/sketch-react](https://zjuasmn.github.io/sketch-react/) 4 | 5 | Project is still in beta, help and advise are welcome. 6 | 7 | The goal of this project is trying to reduce the gap between UI design and front-end development \(for now, just html\). A lot of time of front-end development is spent in matching the given UI design instead of implementing application logic. There are collaboration tools like [Zeplin ](https://www.zeplin.io/) to help marking the design, but programmer still needs to measure and copy css for each element assemble them and copy icons and other assets one by one. 8 | 9 | Here, we take a step further by generate html from .sketch file\(v43+\). So you can copy a group of element as html code and paste it in online or local text editor. Just a copy and paste from design to working code. Enjoy it. 10 | 11 | ![](/assets/demo.gif) 12 | 13 | # ![](/assets/screenshot.jpg)Roadmap 14 | 15 | * export to React code 16 | * autoprefix css 17 | * reduce export code size 18 | * svg code optimization 19 | * class style generate from symbol, share style 20 | * optimize position css 21 | * detect row or column pattern of elements 22 | * detect padding 23 | * detect corner element 24 | * minor edit 25 | * visible & lock 26 | * ... 27 | 28 | # Limitation 29 | 30 | Due to the limitation of html, it cannot be 100% math the original design, like background blur, multiple borders, multiple fills, masking, image color adjust and so on. 31 | 32 | **General** 33 | 34 | | Feature | Support | Related CSS Attributes | 35 | | :--- | :--- | :--- | 36 | | Position | Support | top, left | 37 | | Size | Support | width, height | 38 | | rotation, flip | Support | transform | 39 | | Opacity | Support | opacity | 40 | | Blending | Support | mix-blend-mode | 41 | | Lock | No | | 42 | | Hide | Support | display:none | 43 | 44 | **Text**\(rendered as <span/>\) 45 | 46 | | Feature | Support | Related CSS Attributes | 47 | | :--- | :--- | :--- | 48 | | Typeface | Limit\(no fallback\) | font-family | 49 | | Weight | Support | font-weight | 50 | | Color | Limit\(only solid color\) | color | 51 | | Size | Support | font-size | 52 | | Alignment | Support | text-align | 53 | | Width | Limit | \(Auto\)width:auto\(Fixed\)width:?px | 54 | | Spacing-Character | No | letter-spacing | 55 | | Spacing-Line | Limit | line-height | 56 | | Spacing-Paragraph | No | | 57 | | Fills | No | | 58 | | Borders | No | | 59 | | Shadows | No | | 60 | | Blur | No | | 61 | 62 | **Symbol** 63 | 64 | | Feature | Support | Related CSS Attributes | 65 | | :--- | :--- | :--- | 66 | | Size | Limit\(only support same size as symbol master\) | | 67 | | Overrides | no | | 68 | 69 | **Image** \(render as <img/>\) 70 | 71 | | Feature | Support | Related CSS Attributes | 72 | | :--- | :--- | :--- | 73 | | Color Adjust | no | | 74 | 75 | **Group**\(<div/>\) 76 | 77 | | Feature | Support | Related CSS Attributes | 78 | | :--- | :--- | :--- | 79 | | mask | limit\(only when the mask is the most bottom layer and it is simple shape and all sibling layer don't ignore the mask\) | overflow:hidden | 80 | 81 | **Shape** 82 | 83 | Simple Shape\(<div/>\) 84 | 85 | A shape is considered as simple when it is a rectangle or Oval with its points unedited. 86 | 87 | Complex Shape\(<svg/>\) 88 | 89 | complex shape needed to be flatten before render. 90 | 91 | | Feature | Support | Related CSS Attributes | 92 | | :--- | :--- | :--- | 93 | | fill | limit\(only one fill with solid color\) | fill | 94 | | borders | limit\(only one border with solid color\) | stroke,stroke-width | 95 | | shadows & innerShadows | no | | 96 | | blur | no | | 97 | | bool operation | no | | 98 | 99 | 100 | 101 | -------------------------------------------------------------------------------- /assets/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zjuasmn/sketch-react/8582552e8538cdf507f6899724d4a9124028d394/assets/demo.gif -------------------------------------------------------------------------------- /assets/screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zjuasmn/sketch-react/8582552e8538cdf507f6899724d4a9124028d394/assets/screenshot.jpg -------------------------------------------------------------------------------- /assets/screenshot2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zjuasmn/sketch-react/8582552e8538cdf507f6899724d4a9124028d394/assets/screenshot2.jpg -------------------------------------------------------------------------------- /cosmos.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | componentPaths: ['src/components'], 3 | publicPath: __dirname + '/public', 4 | webpackConfigPath: './webpack.config.js', 5 | }; -------------------------------------------------------------------------------- /demo/simple/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "es2015", 4 | "react", 5 | "stage-0" 6 | ], 7 | "plugins": [ 8 | ] 9 | } -------------------------------------------------------------------------------- /demo/simple/index.css: -------------------------------------------------------------------------------- 1 | html, body, #root { 2 | height: 100%; 3 | margin: 0; 4 | padding: 0; 5 | } 6 | body{ 7 | font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; 8 | } 9 | .header { 10 | height: 32px; 11 | display: flex; 12 | align-items: center; 13 | padding: 0 16px; 14 | box-shadow: 0 1px 0 0 #b2b2b2; 15 | } 16 | 17 | .header a { 18 | color: #666; 19 | text-decoration: none; 20 | /*cursor: pointer;*/ 21 | } 22 | 23 | .header a:hover { 24 | color: #333; 25 | } 26 | 27 | .flex { 28 | flex: 1; 29 | } 30 | 31 | .title { 32 | text-decoration: none; 33 | font-size: 18px; 34 | color: #666; 35 | width: 120px; 36 | } 37 | 38 | .filename { 39 | flex: 1; 40 | text-align: center; 41 | color: #666; 42 | font-size: 16px; 43 | 44 | } 45 | 46 | .title:hover { 47 | color: #333; 48 | } 49 | 50 | .icon { 51 | fill: #999; 52 | 53 | } 54 | 55 | .actions { 56 | width: 120px; 57 | } 58 | 59 | .icon-link { 60 | display: block; 61 | float: right; 62 | height: 16px; 63 | } 64 | 65 | .icon-link:hover .icon { 66 | fill: #333; 67 | } 68 | 69 | .upload-area { 70 | margin: 64px auto 0; 71 | height: 180px; 72 | width: 320px; 73 | padding: 16px; 74 | border: 2px dashed #666; 75 | display: flex; 76 | flex-direction: column; 77 | } 78 | .upload-area .desc{ 79 | flex: 1; 80 | text-align: center; 81 | padding-top: 32px; 82 | font-size: 16px; 83 | color: #666; 84 | } 85 | .upload-button { 86 | cursor: pointer; 87 | display: block; 88 | background: #3884ff; 89 | color: white; 90 | text-align: center; 91 | padding: 4px 16px; 92 | border-radius: 3px; 93 | font-size: 14px; 94 | line-height: 24px; 95 | } 96 | 97 | .sample { 98 | margin: 16px auto; 99 | text-align: center; 100 | } 101 | .sample-header{ 102 | color: #666; 103 | } 104 | .sample-link{ 105 | cursor: pointer; 106 | 107 | font-size: 16px; 108 | line-height: 24px; 109 | margin-top: 8px; 110 | } 111 | .sample-link a{ 112 | color: #3884ff; 113 | text-decoration: underline; 114 | } -------------------------------------------------------------------------------- /demo/simple/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Sketch React 7 | 8 | 9 |
10 | 11 | -------------------------------------------------------------------------------- /demo/simple/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import App from '../../src/components/Site/App' 4 | 5 | ReactDOM.render(, document.getElementById('root')); -------------------------------------------------------------------------------- /demo/simple/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "simple", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "webpack": "webpack", 8 | "release": "webpack -p", 9 | "watch": "webpack -w", 10 | "dev": "webpack-dev-server" 11 | }, 12 | "devDependencies": { 13 | "babel-core": "^6.24.1", 14 | "babel-preset-es2015": "^6.24.1", 15 | "babel-preset-react": "^6.24.1", 16 | "babel-preset-stage-0": "^6.24.1", 17 | "babel-register": "^6.24.1", 18 | "html-webpack-plugin": "^2.28.0", 19 | "stylus": "^0.54.5", 20 | "stylus-loader": "^3.0.1", 21 | "webpack": "^2.4.1", 22 | "webpack-dev-server": "^2.4.5" 23 | }, 24 | "dependencies": { 25 | "react": "^15.5.4", 26 | "react-dom": "^15.5.4" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /demo/simple/webpack.config.js: -------------------------------------------------------------------------------- 1 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 2 | const path = require('path'); 3 | 4 | module.exports = { 5 | entry: { 6 | index: path.resolve(__dirname, 'index.js'), 7 | }, 8 | output: { 9 | path: path.resolve(__dirname, '../..'), 10 | filename: 'main.js', 11 | }, 12 | module: { 13 | rules: [ 14 | { 15 | test: /\.jsx?$/, 16 | exclude: [ 17 | /node_modules/ 18 | ], 19 | loaders: ["babel-loader"] 20 | }, 21 | { 22 | test: /\.styl$/, 23 | loaders: ['style-loader', 'css-loader', "stylus-loader"] 24 | }, 25 | { 26 | test: /\.css$/, 27 | loaders: ['style-loader', 'css-loader'] 28 | }, 29 | { 30 | test: /\.(png)$/, 31 | loaders: ["file-loader?name=[name].[ext]&outputPath=images/"] 32 | }, 33 | ] 34 | 35 | }, 36 | resolve: { 37 | extensions: [".js", 'jsx', ".json"], 38 | }, 39 | plugins: [new HtmlWebpackPlugin({ 40 | template: 'index.html' 41 | })], 42 | }; -------------------------------------------------------------------------------- /images/Fitness App.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zjuasmn/sketch-react/8582552e8538cdf507f6899724d4a9124028d394/images/Fitness App.sketch -------------------------------------------------------------------------------- /images/bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zjuasmn/sketch-react/8582552e8538cdf507f6899724d4a9124028d394/images/bg.png -------------------------------------------------------------------------------- /images/folder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zjuasmn/sketch-react/8582552e8538cdf507f6899724d4a9124028d394/images/folder.png -------------------------------------------------------------------------------- /images/ui-video-simple-john-hansen.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zjuasmn/sketch-react/8582552e8538cdf507f6899724d4a9124028d394/images/ui-video-simple-john-hansen.sketch -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Sketch React 7 | 8 | 9 |
10 | 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sketch-view", 3 | "version": "1.0.0", 4 | "main": "src/index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "cosmos": "cosmos" 8 | }, 9 | "devDependencies": { 10 | "babel-core": "^6.24.1", 11 | "babel-preset-es2015": "^6.24.1", 12 | "babel-preset-react": "^6.24.1", 13 | "babel-preset-stage-0": "^6.24.1", 14 | "babel-register": "^6.24.1", 15 | "brfs": "^1.4.3", 16 | "chai": "^3.5.0", 17 | "file-loader": "^0.11.1", 18 | "html-webpack-plugin": "^2.28.0", 19 | "jsdom": "^10.1.0", 20 | "mocha": "^3.3.0", 21 | "react-addons-perf": "^15.4.2", 22 | "react-cosmos-webpack": "^2.0.0-beta.11", 23 | "react-dom": "^15.5.4", 24 | "stylus": "^0.54.5", 25 | "stylus-loader": "^3.0.1", 26 | "transform-loader": "^0.2.4", 27 | "webpack": "^2.5.1" 28 | }, 29 | "dependencies": { 30 | "babel-polyfill": "^6.23.0", 31 | "big-integer": "^1.6.22", 32 | "classnames": "^2.2.5", 33 | "highlight.js": "^9.11.0", 34 | "is-plain-object": "^2.0.1", 35 | "isomorphic-fetch": "^2.2.1", 36 | "jszip": "^3.1.3", 37 | "lodash": "^4.17.4", 38 | "pretty-bytes": "^4.0.2", 39 | "prop-types": "^15.5.8", 40 | "react": "^15.5.4", 41 | "react-utilities": "^0.5.4", 42 | "react-zeroclipboard": "^3.2.3", 43 | "select": "^1.1.2", 44 | "sketch-constants": "^1.0.2" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/__tests__/Style.spec.js: -------------------------------------------------------------------------------- 1 | import {expect} from 'chai' 2 | import {Color, Border, Shadow, InnerShadow, Gradient, Fill, Style, TextStyle,ShapePath} from '../models' 3 | import {parse} from '../utils' 4 | 5 | 6 | describe('Color', () => { 7 | it('works', () => { 8 | let json = { 9 | "_class": "color", 10 | "alpha": 0.89, 11 | "blue": 0.592, 12 | "green": 0, 13 | "red": 1 14 | }; 15 | let color = parse(json); 16 | expect(color).to.be.an.instanceof(Color); 17 | expect(`${color}`).to.equal('rgba(255,0,151,0.89)'); 18 | }) 19 | }); 20 | describe('border', () => { 21 | it('works', () => { 22 | let json = { 23 | "_class": "border", 24 | "isEnabled": true, 25 | "color": { 26 | "_class": "color", 27 | "alpha": 1, 28 | "blue": 0.592, 29 | "green": 0.592, 30 | "red": 0.592 31 | }, 32 | "fillType": 0, 33 | "position": 1, 34 | "thickness": 1 35 | }; 36 | 37 | let border = parse(json); 38 | expect(border).to.be.an.instanceof(Border); 39 | expect(`${border}`).to.equal('1px solid rgba(151,151,151,1)'); 40 | }) 41 | }); 42 | describe('shadow and innetShadow', () => { 43 | it('works', () => { 44 | let json = { 45 | "_class": "shadow", 46 | "isEnabled": true, 47 | "blurRadius": 4, 48 | "color": { 49 | "_class": "color", 50 | "alpha": 0.5, 51 | "blue": 0, 52 | "green": 0, 53 | "red": 0 54 | }, 55 | "contextSettings": { 56 | "_class": "graphicsContextSettings", 57 | "blendMode": 0, 58 | "opacity": 1 59 | }, 60 | "offsetX": 0, 61 | "offsetY": 2, 62 | "spread": 0 63 | }; 64 | let shadow = parse(json); 65 | expect(shadow).to.be.an.instanceof(Shadow); 66 | expect(`${shadow}`).to.equal('0px 2px 4px 0px rgba(0,0,0,0.5)'); 67 | }); 68 | it('works innerShadow', () => { 69 | let json = { 70 | "_class": "innerShadow", 71 | "isEnabled": true, 72 | "blurRadius": 4, 73 | "color": { 74 | "_class": "color", 75 | "alpha": 0.5, 76 | "blue": 0, 77 | "green": 0, 78 | "red": 0 79 | }, 80 | "contextSettings": { 81 | "_class": "graphicsContextSettings", 82 | "blendMode": 0, 83 | "opacity": 1 84 | }, 85 | "offsetX": 0, 86 | "offsetY": 2, 87 | "spread": 0 88 | }; 89 | let innerShadow = parse(json); 90 | expect(innerShadow).to.be.an.instanceof(InnerShadow); 91 | expect(`${innerShadow}`).to.equal('inset 0px 2px 4px 0px rgba(0,0,0,0.5)'); 92 | }); 93 | it('works for array of shadow', () => { 94 | let json = [ 95 | { 96 | "_class": "shadow", 97 | "isEnabled": true, 98 | "blurRadius": 4, 99 | "color": { 100 | "_class": "color", 101 | "alpha": 0.5, 102 | "blue": 0, 103 | "green": 0, 104 | "red": 0 105 | }, 106 | "contextSettings": { 107 | "_class": "graphicsContextSettings", 108 | "blendMode": 0, 109 | "opacity": 1 110 | }, 111 | "offsetX": 0, 112 | "offsetY": 2, 113 | "spread": 0 114 | }, 115 | { 116 | "_class": "innerShadow", 117 | "isEnabled": true, 118 | "blurRadius": 4, 119 | "color": { 120 | "_class": "color", 121 | "alpha": 0.5, 122 | "blue": 0, 123 | "green": 0, 124 | "red": 0 125 | }, 126 | "contextSettings": { 127 | "_class": "graphicsContextSettings", 128 | "blendMode": 0, 129 | "opacity": 1 130 | }, 131 | "offsetX": 0, 132 | "offsetY": 2, 133 | "spread": 0 134 | }, 135 | ]; 136 | let shadows = parse(json); 137 | expect(shadows.map(shadow => shadow.toString()).join(',')).to.equal('0px 2px 4px 0px rgba(0,0,0,0.5),inset 0px 2px 4px 0px rgba(0,0,0,0.5)'); 138 | }) 139 | }) 140 | describe(`gradient`, () => { 141 | it('works linear-gradient', () => { 142 | let json = { 143 | "_class": "gradient", 144 | "elipseLength": 0, 145 | "from": "{0.5, 0}", 146 | "gradientType": 0, 147 | "shouldSmoothenOpacity": false, 148 | "stops": [ 149 | { 150 | "_class": "gradientStop", 151 | "color": { 152 | "_class": "color", 153 | "alpha": 1, 154 | "blue": 1, 155 | "green": 1, 156 | "red": 1 157 | }, 158 | "position": 0 159 | }, 160 | { 161 | "_class": "gradientStop", 162 | "color": { 163 | "_class": "color", 164 | "alpha": 1, 165 | "blue": 0, 166 | "green": 0, 167 | "red": 0 168 | }, 169 | "position": 1 170 | } 171 | ], 172 | "to": "{0.85485408251402162, 0.64973154574674941}" 173 | }; 174 | let gradient = parse(json); 175 | expect(gradient).to.be.an.instanceof(Gradient); 176 | expect(`${gradient}`).to.equal('linear-gradient(0deg, rgba(255,255,255,1) 0%, rgba(0,0,0,1) 100%)'); 177 | }) 178 | }); 179 | describe('fill', () => { 180 | it('works', () => { 181 | let json = { 182 | "_class": "fill", 183 | "isEnabled": true, 184 | "color": { 185 | "_class": "color", 186 | "alpha": 1, 187 | "blue": 0.847, 188 | "green": 0.847, 189 | "red": 0.847 190 | }, 191 | "fillType": 0, 192 | }; 193 | let fill = parse(json); 194 | expect(fill).to.be.an.instanceof(Fill); 195 | expect(`${fill}`).to.equal('linear-gradient(0deg, rgba(216,216,216,1),rgba(216,216,216,1))'); 196 | }); 197 | it('works', () => { 198 | let json = { 199 | "_class": "fill", 200 | "isEnabled": true, 201 | "color": { 202 | "_class": "color", 203 | "alpha": 1, 204 | "blue": 0.847, 205 | "green": 0.847, 206 | "red": 0.847 207 | }, 208 | "fillType": 1, 209 | "gradient": { 210 | "_class": "gradient", 211 | "elipseLength": 0, 212 | "from": "{0.5, 0}", 213 | "gradientType": 0, 214 | "shouldSmoothenOpacity": false, 215 | "stops": [ 216 | { 217 | "_class": "gradientStop", 218 | "color": { 219 | "_class": "color", 220 | "alpha": 1, 221 | "blue": 1, 222 | "green": 1, 223 | "red": 1 224 | }, 225 | "position": 0 226 | }, 227 | { 228 | "_class": "gradientStop", 229 | "color": { 230 | "_class": "color", 231 | "alpha": 1, 232 | "blue": 0, 233 | "green": 0, 234 | "red": 0 235 | }, 236 | "position": 1 237 | } 238 | ], 239 | "to": "{0.85485408251402162, 0.64973154574674941}" 240 | }, 241 | }; 242 | let fill = parse(json); 243 | expect(fill).to.be.an.instanceof(Fill); 244 | expect(`${fill}`).to.equal('linear-gradient(0deg, rgba(255,255,255,1) 0%, rgba(0,0,0,1) 100%)'); 245 | }) 246 | }); 247 | describe('Style', () => { 248 | it('works', () => { 249 | let json = { 250 | "_class": "style", 251 | "borders": [ 252 | { 253 | "_class": "border", 254 | "isEnabled": true, 255 | "color": { 256 | "_class": "color", 257 | "alpha": 1, 258 | "blue": 0.592, 259 | "green": 0.592, 260 | "red": 0.592 261 | }, 262 | "fillType": 0, 263 | "position": 1, 264 | "thickness": 1 265 | } 266 | ], 267 | isVisible:true, 268 | "endDecorationType": 0, 269 | "fills": [ 270 | { 271 | "_class": "fill", 272 | "isEnabled": true, 273 | "color": { 274 | "_class": "color", 275 | "alpha": 1, 276 | "blue": 0.847, 277 | "green": 0.847, 278 | "red": 0.847 279 | }, 280 | "fillType": 1, 281 | "gradient": { 282 | "_class": "gradient", 283 | "elipseLength": 0, 284 | "from": "{0.5, 0}", 285 | "gradientType": 0, 286 | "shouldSmoothenOpacity": false, 287 | "stops": [ 288 | { 289 | "_class": "gradientStop", 290 | "color": { 291 | "_class": "color", 292 | "alpha": 1, 293 | "blue": 1, 294 | "green": 1, 295 | "red": 1 296 | }, 297 | "position": 0 298 | }, 299 | { 300 | "_class": "gradientStop", 301 | "color": { 302 | "_class": "color", 303 | "alpha": 1, 304 | "blue": 0, 305 | "green": 0, 306 | "red": 0 307 | }, 308 | "position": 1 309 | } 310 | ], 311 | "to": "{0.85485408251402162, 0.64973154574674941}" 312 | }, 313 | "noiseIndex": 0, 314 | "noiseIntensity": 0, 315 | "patternFillType": 0, 316 | "patternTileScale": 1 317 | }, 318 | { 319 | "_class": "fill", 320 | "isEnabled": true, 321 | "color": { 322 | "_class": "color", 323 | "alpha": 1, 324 | "blue": 0.847, 325 | "green": 0.847, 326 | "red": 0.847 327 | }, 328 | "fillType": 1, 329 | "gradient": { 330 | "_class": "gradient", 331 | "elipseLength": 0, 332 | "from": "{0.5, 0}", 333 | "gradientType": 0, 334 | "shouldSmoothenOpacity": false, 335 | "stops": [ 336 | { 337 | "_class": "gradientStop", 338 | "color": { 339 | "_class": "color", 340 | "alpha": 0.5, 341 | "blue": 0.3258815412299212, 342 | "green": 0.3258815412299212, 343 | "red": 0.5080516581632653 344 | }, 345 | "position": 0 346 | }, 347 | { 348 | "_class": "gradientStop", 349 | "color": { 350 | "_class": "color", 351 | "alpha": 0.5, 352 | "blue": 0, 353 | "green": 0, 354 | "red": 0 355 | }, 356 | "position": 1 357 | } 358 | ], 359 | "to": "{0.5, 1}" 360 | }, 361 | "noiseIndex": 0, 362 | "noiseIntensity": 0, 363 | "patternFillType": 1, 364 | "patternTileScale": 1 365 | } 366 | ], 367 | "miterLimit": 10, 368 | "startDecorationType": 0, 369 | "innerShadows": [ 370 | { 371 | "_class": "innerShadow", 372 | "isEnabled": true, 373 | "blurRadius": 3, 374 | "color": { 375 | "_class": "color", 376 | "alpha": 0.5, 377 | "blue": 0, 378 | "green": 0, 379 | "red": 0 380 | }, 381 | "contextSettings": { 382 | "_class": "graphicsContextSettings", 383 | "blendMode": 0, 384 | "opacity": 1 385 | }, 386 | "offsetX": 0, 387 | "offsetY": 1, 388 | "spread": 0 389 | } 390 | ], 391 | "shadows": [ 392 | { 393 | "_class": "shadow", 394 | "isEnabled": true, 395 | "blurRadius": 4, 396 | "color": { 397 | "_class": "color", 398 | "alpha": 0.5, 399 | "blue": 0, 400 | "green": 0, 401 | "red": 0 402 | }, 403 | "contextSettings": { 404 | "_class": "graphicsContextSettings", 405 | "blendMode": 0, 406 | "opacity": 1 407 | }, 408 | "offsetX": 0, 409 | "offsetY": 2, 410 | "spread": 0 411 | } 412 | ], 413 | }; 414 | let style = parse(json); 415 | expect(style).to.be.an.instanceof(Style); 416 | expect(style.toStyle()).to.eql({ 417 | "background": "linear-gradient(0deg, rgba(255,255,255,1) 0%, rgba(0,0,0,1) 100%), linear-gradient(0deg, rgba(130,83,83,0.5) 0%, rgba(0,0,0,0.5) 100%)", 418 | "border": "1px solid rgba(151,151,151,1)", 419 | "boxShadow": "0px 2px 4px 0px rgba(0,0,0,0.5), inset 0px 1px 3px 0px rgba(0,0,0,0.5)", 420 | "boxSizing": "border-box", 421 | "pointerEvent": "auto", 422 | }); 423 | }) 424 | }); 425 | describe('TextStyle', () => { 426 | it('works', () => { 427 | let json = { 428 | "_class": "textStyle", 429 | "encodedAttributes": { 430 | "NSKern": 1, 431 | "MSAttributedStringTextTransformAttribute": 1, 432 | "NSColor": { 433 | "_archive": "YnBsaXN0MDDUAQIDBAUGHyBYJHZlcnNpb25YJG9iamVjdHNZJGFyY2hpdmVyVCR0b3ASAAGGoKUHCBEVHFUkbnVsbNQJCgsMDQ4PEFVOU1JHQlxOU0NvbG9yU3BhY2VfEBJOU0N1c3RvbUNvbG9yU3BhY2VWJGNsYXNzTxAsMC42Mzc2NDg4MDk1IDAuMjAxNTQyMjEwNyAwLjIwMTU0MjIxMDcgMC42NQAQAYACgATSEgwTFFROU0lEEAGAA9IWFxgZWiRjbGFzc25hbWVYJGNsYXNzZXNcTlNDb2xvclNwYWNlohobXE5TQ29sb3JTcGFjZVhOU09iamVjdNIWFx0eV05TQ29sb3KiHRtfEA9OU0tleWVkQXJjaGl2ZXLRISJUcm9vdIABAAgAEQAaACMALQAyADcAPQBDAEwAUgBfAHQAewCqAKwArgCwALUAugC8AL4AwwDOANcA5ADnAPQA\/QECAQoBDQEfASIBJwAAAAAAAAIBAAAAAAAAACMAAAAAAAAAAAAAAAAAAAEp" 434 | }, 435 | "NSStrikethrough": 0, 436 | "MSAttributedStringFontAttribute": { 437 | "_archive": "YnBsaXN0MDDUAQIDBAUGJidYJHZlcnNpb25YJG9iamVjdHNZJGFyY2hpdmVyVCR0b3ASAAGGoKkHCA0XGBkaGyJVJG51bGzSCQoLDFYkY2xhc3NfEBpOU0ZvbnREZXNjcmlwdG9yQXR0cmlidXRlc4AIgALTDg8JEBMWV05TLmtleXNaTlMub2JqZWN0c6IREoADgASiFBWABYAGgAdfEBNOU0ZvbnRTaXplQXR0cmlidXRlXxATTlNGb250TmFtZUF0dHJpYnV0ZSNAOAAAAAAAAFdBcmlhbE1U0hwdHh9aJGNsYXNzbmFtZVgkY2xhc3Nlc18QE05TTXV0YWJsZURpY3Rpb25hcnmjHiAhXE5TRGljdGlvbmFyeVhOU09iamVjdNIcHSMkXxAQTlNGb250RGVzY3JpcHRvcqIlIV8QEE5TRm9udERlc2NyaXB0b3JfEA9OU0tleWVkQXJjaGl2ZXLRKClUcm9vdIABAAgAEQAaACMALQAyADcAQQBHAEwAUwBwAHIAdAB7AIMAjgCRAJMAlQCYAJoAnACeALQAygDTANsA4ADrAPQBCgEOARsBJAEpATwBPwFSAWQBZwFsAAAAAAAAAgEAAAAAAAAAKgAAAAAAAAAAAAAAAAAAAW4=" 438 | }, 439 | "NSUnderline": 1, 440 | "NSParagraphStyle": { 441 | "_archive": "YnBsaXN0MDDUAQIDBAUGb3BYJHZlcnNpb25YJG9iamVjdHNZJGFyY2hpdmVyVCR0b3ASAAGGoK8QGAcIGisvNTc6PUBDRklMT1JVWFteYmZna1UkbnVsbNkJCgsMDQ4PEBESExQVFhcVGBlWJGNsYXNzWk5TVGFiU3RvcHNcTlNUZXh0QmxvY2tzXxAPTlNNYXhMaW5lSGVpZ2h0XxASTlNQYXJhZ3JhcGhTcGFjaW5nW05TVGV4dExpc3RzXxAPTlNNaW5MaW5lSGVpZ2h0XE5TSGVhZEluZGVudFtOU0FsaWdubWVudIAXgAKAEiNAAAAAAAAAACNACAAAAAAAAIATI0BCAAAAAAAAEAHSGwkcKlpOUy5vYmplY3RzrR0eHyAhIiMkJSYnKCmAA4AFgAaAB4AIgAmACoALgAyADYAOgA+AEIAR0gksLS5aTlNMb2NhdGlvboAEI0AmAAAAAAAA0jAxMjNaJGNsYXNzbmFtZVgkY2xhc3Nlc1lOU1RleHRUYWKiMjRYTlNPYmplY3TSCSwtGIAE0gksLTmABCNATFMzQAAAANIJLC08gAQjQFVAAAAAAADSCSwtP4AEI0BcVmZgAAAA0gksLUKABCNAYbZmYAAAANIJLC1FgAQjQGVBmaAAAADSCSwtSIAEI0BozMzAAAAA0gksLUuABCNAbFgAAAAAANIJLC1OgAQjQG\/jM0AAAADSCSwtUYAEI0BxtzNAAAAA0gksLVSABCNAc3zMwAAAANIJLC1XgAQjQHVCZmAAAADSMDFZWldOU0FycmF5olk00hsJXCqggBHSGwlfKqFggBSAEdIJY2RlXk5TTWFya2VyRm9ybWF0gBaAFVp7ZGVjaW1hbH0u0jAxaGlaTlNUZXh0TGlzdKJqNFpOU1RleHRMaXN00jAxbG1fEBdOU011dGFibGVQYXJhZ3JhcGhTdHlsZaNsbjRfEBBOU1BhcmFncmFwaFN0eWxlXxAPTlNLZXllZEFyY2hpdmVy0XFyVHJvb3SAAQAIABEAGgAjAC0AMgA3AFIAWABrAHIAfQCKAJwAsQC9AM8A3ADoAOoA7ADuAPcBAAECAQsBDQESAR0BKwEtAS8BMQEzATUBNwE5ATsBPQE\/AUEBQwFFAUcBTAFXAVkBYgFnAXIBewGFAYgBkQGWAZgBnQGfAagBrQGvAbgBvQG\/AcgBzQHPAdgB3QHfAegB7QHvAfgB\/QH\/AggCDQIPAhgCHQIfAigCLQIvAjgCPQI\/AkgCTQJVAlgCXQJeAmACZQJnAmkCawJwAn8CgQKDAo4CkwKeAqECrAKxAssCzwLiAvQC9wL8AAAAAAAAAgEAAAAAAAAAcwAAAAAAAAAAAAAAAAAAAv4=" 442 | } 443 | } 444 | }; 445 | let textStyle = parse(json); 446 | expect(textStyle).to.be.an.instanceof(TextStyle); 447 | 448 | }); 449 | }); 450 | 451 | describe('ShapePath', () => { 452 | it('works', () => { 453 | let json = 454 | { 455 | "_class": "shapePath", 456 | "do_objectID": "4E573388-DCD7-42A3-9138-17E043BAAE00", 457 | "exportOptions": { 458 | "_class": "exportOptions", 459 | "exportFormats": [], 460 | "includedLayerIds": [], 461 | "layerOptions": 0, 462 | "shouldTrim": false 463 | }, 464 | "frame": { 465 | "_class": "rect", 466 | "constrainProportions": false, 467 | "height": 10, 468 | "width": 10, 469 | "x": 0, 470 | "y": 0 471 | }, 472 | "isFlippedHorizontal": false, 473 | "isFlippedVertical": false, 474 | "isLocked": false, 475 | "isVisible": true, 476 | "layerListExpandedType": 0, 477 | "name": "Path", 478 | "nameIsFixed": false, 479 | "resizingType": 0, 480 | "rotation": 0, 481 | "shouldBreakMaskChain": false, 482 | "booleanOperation": -1, 483 | "edited": true, 484 | "path": { 485 | "_class": "path", 486 | "isClosed": true, 487 | "points": [ 488 | { 489 | "_class": "curvePoint", 490 | "cornerRadius": 1, 491 | "curveFrom": "{0.80000000000000004, 0.20000000000000001}", 492 | "curveMode": 1, 493 | "curveTo": "{0.80000000000000004, 0.20000000000000001}", 494 | "hasCurveFrom": false, 495 | "hasCurveTo": false, 496 | "point": "{0.80000000000000004, 0.20000000000000001}" 497 | }, 498 | { 499 | "_class": "curvePoint", 500 | "cornerRadius": 0, 501 | "curveFrom": "{1, 0.20000000000000001}", 502 | "curveMode": 1, 503 | "curveTo": "{1, 0.20000000000000001}", 504 | "hasCurveFrom": false, 505 | "hasCurveTo": false, 506 | "point": "{1, 0.20000000000000001}" 507 | }, 508 | { 509 | "_class": "curvePoint", 510 | "cornerRadius": 0, 511 | "curveFrom": "{1, 1}", 512 | "curveMode": 1, 513 | "curveTo": "{1, 1}", 514 | "hasCurveFrom": false, 515 | "hasCurveTo": false, 516 | "point": "{1, 1}" 517 | }, 518 | { 519 | "_class": "curvePoint", 520 | "cornerRadius": 0, 521 | "curveFrom": "{0.20000000000000001, 1}", 522 | "curveMode": 1, 523 | "curveTo": "{0.20000000000000001, 1}", 524 | "hasCurveFrom": false, 525 | "hasCurveTo": false, 526 | "point": "{0.20000000000000001, 1}" 527 | }, 528 | { 529 | "_class": "curvePoint", 530 | "cornerRadius": 0, 531 | "curveFrom": "{0.10000000000000005, 0.70000000000000007}", 532 | "curveMode": 2, 533 | "curveTo": "{0.29999999999999999, 0.90000000000000002}", 534 | "hasCurveFrom": true, 535 | "hasCurveTo": true, 536 | "point": "{0.20000000000000001, 0.80000000000000004}" 537 | }, 538 | { 539 | "_class": "curvePoint", 540 | "cornerRadius": 0, 541 | "curveFrom": "{0, 0.80000000000000004}", 542 | "curveMode": 1, 543 | "curveTo": "{0, 0.80000000000000004}", 544 | "hasCurveFrom": false, 545 | "hasCurveTo": false, 546 | "point": "{0, 0.80000000000000004}" 547 | }, 548 | { 549 | "_class": "curvePoint", 550 | "cornerRadius": 0, 551 | "curveFrom": "{0, 0}", 552 | "curveMode": 1, 553 | "curveTo": "{0, 0}", 554 | "hasCurveFrom": false, 555 | "hasCurveTo": false, 556 | "point": "{0, 0}" 557 | }, 558 | { 559 | "_class": "curvePoint", 560 | "cornerRadius": 0, 561 | "curveFrom": "{0.80000000000000004, 0}", 562 | "curveMode": 1, 563 | "curveTo": "{0.80000000000000004, 0}", 564 | "hasCurveFrom": false, 565 | "hasCurveTo": false, 566 | "point": "{0.80000000000000004, 0}" 567 | } 568 | ] 569 | } 570 | }; 571 | let shapePath = parse(json); 572 | expect(shapePath).to.be.an.instanceof(ShapePath); 573 | console.log(shapePath.toD()); 574 | }) 575 | }); -------------------------------------------------------------------------------- /src/__tests__/utils.spec.js: -------------------------------------------------------------------------------- 1 | import {expect} from 'chai' 2 | import {parseBase64} from '../utils/bplist-parser' 3 | import {getReactCode,cssText2obj,cssText2jsxCode,getFormattedJSXCode} from '../utils/JSX' 4 | import {JSDOM} from 'jsdom' 5 | 6 | describe('bplist-parser', () => { 7 | it('works', () => { 8 | let s = 'YnBsaXN0MDDUAQIDBAUGHyBYJHZlcnNpb25YJG9iamVjdHNZJGFyY2hpdmVyVCR0b3ASAAGGoKUHCBEVHFUkbnVsbNQJCgsMDQ4PEFVOU1JHQlxOU0NvbG9yU3BhY2VfEBJOU0N1c3RvbUNvbG9yU3BhY2VWJGNsYXNzTxAsMC42Mzc2NDg4MDk1IDAuMjAxNTQyMjEwNyAwLjIwMTU0MjIxMDcgMC42NQAQAYACgATSEgwTFFROU0lEEAGAA9IWFxgZWiRjbGFzc25hbWVYJGNsYXNzZXNcTlNDb2xvclNwYWNlohobXE5TQ29sb3JTcGFjZVhOU09iamVjdNIWFx0eV05TQ29sb3KiHRtfEA9OU0tleWVkQXJjaGl2ZXLRISJUcm9vdIABAAgAEQAaACMALQAyADcAPQBDAEwAUgBfAHQAewCqAKwArgCwALUAugC8AL4AwwDOANcA5ADnAPQA\/QECAQoBDQEfASIBJwAAAAAAAAIBAAAAAAAAACMAAAAAAAAAAAAAAAAAAAEp'; 9 | expect(parseBase64(s)).to.eql({ 10 | "alpha": 0.65, 11 | "blue": 0.2015422107, 12 | "green": 0.2015422107, 13 | "red": 0.6376488095, 14 | }); 15 | s = "YnBsaXN0MDDUAQIDBAUGJidYJHZlcnNpb25YJG9iamVjdHNZJGFyY2hpdmVyVCR0b3ASAAGGoKkHCA0XGBkaGyJVJG51bGzSCQoLDFYkY2xhc3NfEBpOU0ZvbnREZXNjcmlwdG9yQXR0cmlidXRlc4AIgALTDg8JEBMWV05TLmtleXNaTlMub2JqZWN0c6IREoADgASiFBWABYAGgAdfEBNOU0ZvbnRTaXplQXR0cmlidXRlXxATTlNGb250TmFtZUF0dHJpYnV0ZSNAOAAAAAAAAFdBcmlhbE1U0hwdHh9aJGNsYXNzbmFtZVgkY2xhc3Nlc18QE05TTXV0YWJsZURpY3Rpb25hcnmjHiAhXE5TRGljdGlvbmFyeVhOU09iamVjdNIcHSMkXxAQTlNGb250RGVzY3JpcHRvcqIlIV8QEE5TRm9udERlc2NyaXB0b3JfEA9OU0tleWVkQXJjaGl2ZXLRKClUcm9vdIABAAgAEQAaACMALQAyADcAQQBHAEwAUwBwAHIAdAB7AIMAjgCRAJMAlQCYAJoAnACeALQAygDTANsA4ADrAPQBCgEOARsBJAEpATwBPwFSAWQBZwFsAAAAAAAAAgEAAAAAAAAAKgAAAAAAAAAAAAAAAAAAAW4="; 16 | expect(parseBase64(s)).to.eql({ 17 | "NSFontDescriptorAttributes": { 18 | "NSFontNameAttribute": "ArialMT", 19 | "NSFontSizeAttribute": 24, 20 | } 21 | }); 22 | s = "YnBsaXN0MDDUAQIDBAUGb3BYJHZlcnNpb25YJG9iamVjdHNZJGFyY2hpdmVyVCR0b3ASAAGGoK8QGAcIGisvNTc6PUBDRklMT1JVWFteYmZna1UkbnVsbNkJCgsMDQ4PEBESExQVFhcVGBlWJGNsYXNzWk5TVGFiU3RvcHNcTlNUZXh0QmxvY2tzXxAPTlNNYXhMaW5lSGVpZ2h0XxASTlNQYXJhZ3JhcGhTcGFjaW5nW05TVGV4dExpc3RzXxAPTlNNaW5MaW5lSGVpZ2h0XE5TSGVhZEluZGVudFtOU0FsaWdubWVudIAXgAKAEiNAAAAAAAAAACNACAAAAAAAAIATI0BCAAAAAAAAEAHSGwkcKlpOUy5vYmplY3RzrR0eHyAhIiMkJSYnKCmAA4AFgAaAB4AIgAmACoALgAyADYAOgA+AEIAR0gksLS5aTlNMb2NhdGlvboAEI0AmAAAAAAAA0jAxMjNaJGNsYXNzbmFtZVgkY2xhc3Nlc1lOU1RleHRUYWKiMjRYTlNPYmplY3TSCSwtGIAE0gksLTmABCNATFMzQAAAANIJLC08gAQjQFVAAAAAAADSCSwtP4AEI0BcVmZgAAAA0gksLUKABCNAYbZmYAAAANIJLC1FgAQjQGVBmaAAAADSCSwtSIAEI0BozMzAAAAA0gksLUuABCNAbFgAAAAAANIJLC1OgAQjQG\/jM0AAAADSCSwtUYAEI0BxtzNAAAAA0gksLVSABCNAc3zMwAAAANIJLC1XgAQjQHVCZmAAAADSMDFZWldOU0FycmF5olk00hsJXCqggBHSGwlfKqFggBSAEdIJY2RlXk5TTWFya2VyRm9ybWF0gBaAFVp7ZGVjaW1hbH0u0jAxaGlaTlNUZXh0TGlzdKJqNFpOU1RleHRMaXN00jAxbG1fEBdOU011dGFibGVQYXJhZ3JhcGhTdHlsZaNsbjRfEBBOU1BhcmFncmFwaFN0eWxlXxAPTlNLZXllZEFyY2hpdmVy0XFyVHJvb3SAAQAIABEAGgAjAC0AMgA3AFIAWABrAHIAfQCKAJwAsQC9AM8A3ADoAOoA7ADuAPcBAAECAQsBDQESAR0BKwEtAS8BMQEzATUBNwE5ATsBPQE\/AUEBQwFFAUcBTAFXAVkBYgFnAXIBewGFAYgBkQGWAZgBnQGfAagBrQGvAbgBvQG\/AcgBzQHPAdgB3QHfAegB7QHvAfgB\/QH\/AggCDQIPAhgCHQIfAigCLQIvAjgCPQI\/AkgCTQJVAlgCXQJeAmACZQJnAmkCawJwAn8CgQKDAo4CkwKeAqECrAKxAssCzwLiAvQC9wL8AAAAAAAAAgEAAAAAAAAAcwAAAAAAAAAAAAAAAAAAAv4="; 23 | expect(parseBase64(s)).to.eql({ 24 | "NSAlignment": 1, 25 | "NSHeadIndent": 36, 26 | "NSMaxLineHeight": 2, 27 | "NSMinLineHeight": 2, 28 | "NSParagraphSpacing": 3, 29 | "NSTabStops": [ 30 | { 31 | "NSLocation": 11 32 | }, 33 | { 34 | "NSLocation": 36 35 | }, 36 | { 37 | "NSLocation": 56.650001525878906 38 | }, 39 | { 40 | "NSLocation": 85 41 | }, 42 | { 43 | "NSLocation": 113.3499984741211 44 | }, 45 | { 46 | "NSLocation": 141.6999969482422 47 | }, 48 | { 49 | "NSLocation": 170.0500030517578 50 | }, 51 | { 52 | "NSLocation": 198.39999389648438 53 | }, 54 | { 55 | "NSLocation": 226.75 56 | }, 57 | { 58 | "NSLocation": 255.10000610351562 59 | }, 60 | { 61 | "NSLocation": 283.45001220703125 62 | }, 63 | { 64 | "NSLocation": 311.79998779296875 65 | }, 66 | { 67 | "NSLocation": 340.1499938964844 68 | }, 69 | ], 70 | "NSTextBlocks": [], 71 | "NSTextLists": [ 72 | { 73 | "NSMarkerFormat": "{decimal}." 74 | } 75 | ] 76 | }); 77 | 78 | s = "YnBsaXN0MDDUAQIDBAUG5+hYJHZlcnNpb25YJG9iamVjdHNZJGFyY2hpdmVyVCR0b3ASAAGGoK8QOQcIERIYKywtLi8wMTI6PkVISU1VVldYWV03YXGBhYiKjZCTlpmcn6KlqKuusbW5ur7Cw8XX2Nvf41UkbnVsbNQJCgsMDQ4PEFhOU1N0cmluZ18QD05TQXR0cmlidXRlSW5mb1xOU0F0dHJpYnV0ZXNWJGNsYXNzgAKANoADgDhZCTEuIAkxMTFh0hMMFBdaTlMub2JqZWN0c6IVFoAEgDOANdMZEwwaIipXTlMua2V5c6cbHB0eHyAhgAWABoAHgAiACYAKgAunIyQlJiYoKYAMgBCAEYAZgBmAGoAxgDJXTlNDb2xvcl8QD05TU3RyaWtldGhyb3VnaF8QH01TQXR0cmlidXRlZFN0cmluZ0ZvbnRBdHRyaWJ1dGVfEChNU0F0dHJpYnV0ZWRTdHJpbmdUZXh0VHJhbnNmb3JtQXR0cmlidXRlW05TVW5kZXJsaW5lXxAQTlNQYXJhZ3JhcGhTdHlsZVZOU0tlcm7UMzQ1DDY3ODlVTlNSR0JcTlNDb2xvclNwYWNlXxASTlNDdXN0b21Db2xvclNwYWNlTxAsMC42Mzc2NDg4MDk1IDAuMjAxNTQyMjEwNyAwLjIwMTU0MjIxMDcgMC42NQAQAYANgA\/SOww8PVROU0lEEAGADtI\/QEFCWiRjbGFzc25hbWVYJGNsYXNzZXNcTlNDb2xvclNwYWNlokNEXE5TQ29sb3JTcGFjZVhOU09iamVjdNI\/QEZHV05TQ29sb3KiRkQQANIMSktMXxAaTlNGb250RGVzY3JpcHRvckF0dHJpYnV0ZXOAGIAS0xkTDE5RVKJPUIATgBSiUlOAFYAWgBdfEBNOU0ZvbnRTaXplQXR0cmlidXRlXxATTlNGb250TmFtZUF0dHJpYnV0ZSNAOAAAAAAAAFdBcmlhbE1U0j9AWltfEBNOU011dGFibGVEaWN0aW9uYXJ5o1pcRFxOU0RpY3Rpb25hcnnSP0BeX18QEE5TRm9udERlc2NyaXB0b3KiYERfEBBOU0ZvbnREZXNjcmlwdG9y2QxiY2RlZmdoaWprbG1ub21wN1pOU1RhYlN0b3BzXE5TVGV4dEJsb2Nrc18QD05TTWF4TGluZUhlaWdodF8QEk5TUGFyYWdyYXBoU3BhY2luZ1tOU1RleHRMaXN0c18QD05TTWluTGluZUhlaWdodFxOU0hlYWRJbmRlbnRbTlNBbGlnbm1lbnSAMIAbgCsjQAAAAAAAAAAjQAgAAAAAAACALCNAQgAAAAAAANITDHKArXN0dXZ3eHl6e3x9fn+AHIAegB+AIIAhgCKAI4AkgCWAJoAngCiAKYAq0gyCg4RaTlNMb2NhdGlvboAdI0AmAAAAAAAA0j9AhodZTlNUZXh0VGFiooZE0gyCg3CAHdIMgoOMgB0jQExTM0AAAADSDIKDj4AdI0BVQAAAAAAA0gyCg5KAHSNAXFZmYAAAANIMgoOVgB0jQGG2ZmAAAADSDIKDmIAdI0BlQZmgAAAA0gyCg5uAHSNAaMzMwAAAANIMgoOegB0jQGxYAAAAAADSDIKDoYAdI0Bv4zNAAAAA0gyCg6SAHSNAcbczQAAAANIMgoOngB0jQHN8zMAAAADSDIKDqoAdI0B1QmZgAAAA0j9ArK1XTlNBcnJheaKsRNITDK+AoIAq0hMMsoChs4AtgCrSDLa3uF5OU01hcmtlckZvcm1hdIAvgC5ae2RlY2ltYWx9LtI\/QLu8Wk5TVGV4dExpc3SivURaTlNUZXh0TGlzdNI\/QL\/AXxAXTlNNdXRhYmxlUGFyYWdyYXBoU3R5bGWjv8FEXxAQTlNQYXJhZ3JhcGhTdHlsZSM\/8AAAAAAAANI\/QFzEolxE0xkTDMbOKqcbHB0eHyAhgAWABoAHgAiACYAKgAunIyQlJiYo1YAMgBCAEYAZgBmAGoA0gDIQANI\/QNnaXk5TTXV0YWJsZUFycmF5o9msRNLcDN3eV05TLmRhdGFECAABAYA30j9A4OFdTlNNdXRhYmxlRGF0YaPg4kRWTlNEYXRh0j9A5OVfEBJOU0F0dHJpYnV0ZWRTdHJpbmei5kRfEBJOU0F0dHJpYnV0ZWRTdHJpbmdfEA9OU0tleWVkQXJjaGl2ZXLR6epUcm9vdIABAAgAEQAaACMALQAyADcAcwB5AIIAiwCdAKoAsQCzALUAtwC5AMMAyADTANYA2ADaANwA4wDrAPMA9QD3APkA+wD9AP8BAQEJAQsBDQEPAREBEwEVARcBGQEhATMBVQGAAYwBnwGmAa8BtQHCAdcCBgIIAgoCDAIRAhYCGAIaAh8CKgIzAkACQwJQAlkCXgJmAmkCawJwAo0CjwKRApgCmwKdAp8CogKkAqYCqAK+AtQC3QLlAuoDAAMEAxEDFgMpAywDPwNSA10DagN8A5EDnQOvA7wDyAPKA8wDzgPXA+AD4gPrA\/AD\/gQABAIEBAQGBAgECgQMBA4EEAQSBBQEFgQYBBoEHwQqBCwENQQ6BEQERwRMBE4EUwRVBF4EYwRlBG4EcwR1BH4EgwSFBI4EkwSVBJ4EowSlBK4EswS1BL4EwwTFBM4E0wTVBN4E4wTlBO4E8wT1BP4FAwULBQ4FEwUUBRYFGwUdBR8FIQUmBTUFNwU5BUQFSQVUBVcFYgVnBYEFhQWYBaEFpgWpBbAFuAW6BbwFvgXABcIFxAXGBc4F0AXSBdQF1gXYBdoF3AXeBeAF5QX0BfgF\/QYFBgoGDAYRBh8GIwYqBi8GRAZHBlwGbgZxBnYAAAAAAAACAQAAAAAAAADrAAAAAAAAAAAAAAAAAAAGeA=="; 79 | let o = parseBase64(s); 80 | expect(o['NSString']).to.eql("\t1. \t111a"); 81 | }) 82 | }); 83 | 84 | describe('cssText2obj', () => { 85 | it('works', () => { 86 | expect(cssText2obj('font-size : 14px; margin: 1px auto')).to.eql({ 87 | fontSize: '14px', 88 | margin: '1px auto', 89 | }); 90 | }) 91 | }); 92 | describe('cssText2jsxCode', () => { 93 | it('works', () => { 94 | expect(cssText2jsxCode('font-size : 14px; margin: 1px auto')).to.eql(`{fontSize: '14px', margin: '1px auto'}`); 95 | }) 96 | }); 97 | describe('getFormattedJSXCode',()=>{ 98 | it('works',()=>{ 99 | let dom = new JSDOM(``); 100 | let img = dom.window.document.getElementById('img'); 101 | expect(getFormattedJSXCode(img)).to.eql(`\n`); 102 | }); 103 | }); 104 | describe('getReactCode',()=>{ 105 | it('works',()=>{ 106 | let dom = new JSDOM(``); 107 | let img = dom.window.document.getElementById('img'); 108 | expect(getReactCode(img)).to.eql(`import React from 'react' 109 | 110 | export default class MyComp extends React.Component { 111 | render(){ 112 | return ( 113 | 114 | ); 115 | } 116 | }`); 117 | }); 118 | }); -------------------------------------------------------------------------------- /src/components/Document/Document.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import JSZip from "jszip"; 3 | import {clear, parse} from "../../utils"; 4 | import "babel-polyfill"; 5 | import Layer from "../Layer/Layer"; 6 | import PageSelector from "./PageSelector"; 7 | import LayerSelector from "./LayerSelector"; 8 | import PropTypes from "prop-types"; 9 | import styles from "./Document.styl"; 10 | import LayerIndicator from "./LayerIndicator"; 11 | import LayerInfo from "./LayerInfo"; 12 | import Resolve from 'react-utilities/Resolve' 13 | import {Document as DocumentClass} from '../../models' 14 | 15 | let errorPage =
16 |

Cannot parse this file

17 |

If the file is from old-version sketch, open it with new version(v43 or newer) sketch and save to convert.

18 |

If you find any issue, please {' '}feedback 19 | {' '} to me. Thank you.

; 20 | 21 | export default class Document extends React.PureComponent { 22 | static propTypes = { 23 | blob: PropTypes.oneOfType([ 24 | PropTypes.instanceOf(Blob), 25 | PropTypes.instanceOf(Promise), 26 | ]) 27 | }; 28 | 29 | componentWillMount() { 30 | this.document$ = this.loadBlob(this.props.blob); 31 | } 32 | 33 | componentWillUnmount() { 34 | clear(); 35 | } 36 | 37 | async loadBlob(blob) { 38 | let zip = await JSZip.loadAsync(await blob); 39 | let json = JSON.parse(await zip.file('document.json') 40 | .async('string')); 41 | let meta = JSON.parse(await zip.file('meta.json') 42 | .async('string')); 43 | let model = parse(json, zip); 44 | for (let i = 0; i < model.pages.length; ++i) { 45 | let page = model.pages[i] = await model.pages[i].getInstance(); 46 | 47 | let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; 48 | for (let layer of page.layers) { 49 | minX = Math.min(minX, layer.frame.x); 50 | minY = Math.min(minY, layer.frame.y); 51 | maxX = Math.max(maxX, layer.frame.x + layer.frame.width); 52 | maxY = Math.max(maxY, layer.frame.y + layer.frame.height); 53 | } 54 | minX -= 32; 55 | minY -= 32; 56 | maxX += 32; 57 | maxY += 32; 58 | for (let layer of page.layers) { 59 | layer.frame.x -= minX; 60 | layer.frame.y -= minY; 61 | } 62 | page.frame.x = 0; 63 | page.frame.y = 0; 64 | page.frame.width = maxX - minX; 65 | page.frame.height = maxY - minY; 66 | } 67 | model.meta = meta; 68 | return model; 69 | } 70 | 71 | render() { 72 | let {blob, ...props} = this.props; 73 | return } 76 | rejected={errorPage} 77 | {...props} 78 | > 79 | 80 | 81 | } 82 | } 83 | class DocumentViewer extends React.PureComponent { 84 | static propTypes = { 85 | model: PropTypes.instanceOf(DocumentClass), 86 | }; 87 | static childContextTypes = { 88 | onClick: PropTypes.func, 89 | onMouseEnter: PropTypes.func, 90 | onMouseLeave: PropTypes.func, 91 | }; 92 | 93 | constructor(props) { 94 | super(props); 95 | this.state = {}; 96 | this.layerStack = []; 97 | this.model = this.props.model; 98 | } 99 | 100 | getChildContext() { 101 | return { 102 | onClick: this.selectLayer, 103 | onMouseEnter: this.onLayerEnter, 104 | onMouseLeave: this.onLayerLeave 105 | }; 106 | } 107 | 108 | selectPage = (pageID) => { 109 | for (let i = 0; i < this.model.pages.length; ++i) { 110 | if (this.model.pages[i].do_objectID === pageID) { 111 | return this.setState({selectedPage: this.model.pages[i]}); 112 | } 113 | } 114 | }; 115 | 116 | selectLayer = (layerID) => { 117 | return this.setState({selectedLayer: layerID}); 118 | }; 119 | 120 | onLayerEnter = (layerID) => { 121 | this.layerStack.push(layerID); 122 | this.setState({hoveredLayer: this.layerStack[this.layerStack.length - 1]}); 123 | }; 124 | 125 | onLayerLeave = (layerID) => { 126 | if (this.layerStack.length === 0 || layerID !== this.layerStack[this.layerStack.length - 1]) { 127 | console.error('cannot pop layer', this.layerStack, layerID); 128 | } 129 | this.layerStack.pop(); 130 | this.setState({hoveredLayer: this.layerStack[this.layerStack.length - 1]}); 131 | }; 132 | 133 | componentWillMount() { 134 | this.state = {selectedPage: this.props.model.pages[0]}; 135 | } 136 | 137 | render() { 138 | let {selectedPage, selectedLayer, hoveredLayer} = this.state; 139 | let {model, ...props} = this.props; 140 | 141 | return ( 142 |
143 | 158 | < div className={styles.main}> 159 |
163 | 164 | 165 |
166 |
167 | 168 | 171 | 172 | 173 | ); 174 | } 175 | } -------------------------------------------------------------------------------- /src/components/Document/Document.styl: -------------------------------------------------------------------------------- 1 | :local 2 | .document 3 | display flex 4 | box-sizing border-box 5 | width 100% 6 | min-width 480px 7 | height 100% 8 | min-height 300px 9 | border-top 1px solid #B8B8B8 10 | font-family "Helvetica Neue", Helvetica, Arial, sans-serif 11 | .document-error 12 | composes document 13 | background #f2f2f2 14 | color #777 15 | font-size 16px 16 | line-height 1.6 17 | text-align center 18 | display block 19 | h1 20 | max-width 480px 21 | margin 64px auto 0 22 | text-align center 23 | font-size 18px 24 | font-weight bold 25 | p 26 | max-width 480px 27 | margin 16px auto 28 | a 29 | text-align none 30 | color #3884ff 31 | 32 | .document-loading 33 | composes document 34 | composes: global(spin) 35 | background #f2f2f2 36 | align-items center 37 | justify-content center 38 | 39 | .sidebar-selector 40 | width 240px 41 | height 100% 42 | box-sizing border-box 43 | flex none 44 | border-right 1px solid #B8B8B8 45 | .main 46 | position relative 47 | flex 1 48 | overflow auto 49 | height 100% 50 | background #f2f2f2 51 | .canvas 52 | position relative 53 | min-width 100% 54 | min-height 100% 55 | .sidebar-info 56 | width 240px 57 | box-sizing border-box 58 | flex none 59 | height 100% 60 | border-left 1px solid #B8B8B8 61 | 62 | .treeview-item 63 | display flex 64 | align-items center 65 | cursor default 66 | padding-right 8px 67 | &:global(.even) 68 | background #f0f0f0 69 | &.artboard 70 | background #f9f9f9 71 | box-shadow inset 0 -0.5px 0 0 #B8B8B8, inset 0 0.5px 0 0 #B8B8B8 72 | &:global(.hover), &:hover 73 | box-sizing border-box 74 | box-shadow inset 0 0 0 2px #44c1ff 75 | &:global(.hidden) 76 | color #929292 77 | &:global(.selected) 78 | color white 79 | background #6e9ee1 80 | .icon 81 | fill white 82 | color white 83 | .icon-expand 84 | &::after 85 | border-top-color white 86 | .text 87 | flex 1 88 | margin-left 8px 89 | overflow hidden 90 | text-overflow ellipsis 91 | white-space nowrap 92 | .icon 93 | margin-left 8px 94 | flex none 95 | fill #a6a6a6 96 | color #a6a6a6 97 | width 16px 98 | height 16px 99 | line-height 16px 100 | text-align center 101 | user-select none 102 | &.icon-symbol 103 | fill #8f44b7 104 | &.icon-expand 105 | width 12px 106 | height 16px 107 | margin-left 6px 108 | &.icon-mask 109 | transform rotate(90deg) 110 | width 10px 111 | margin-right -4px 112 | color #4e4e4e 113 | .icon-expand 114 | box-sizing border-box 115 | padding 6px 0 4px 116 | &::after 117 | content '' 118 | display block 119 | width 12px 120 | height 6px 121 | box-sizing border-box 122 | border-width 6px 6px 0 123 | border-color #a6a6a6 rgba(0, 0, 0, 0) 124 | border-style solid 125 | .frame 126 | box-sizing border-box 127 | position absolute 128 | pointer-events none 129 | .frame-hovered 130 | composes frame 131 | border 2px solid #44c0ff 132 | .frame-selected 133 | composes frame 134 | border 1px solid #fa571f 135 | .frame-expand 136 | composes frame 137 | border 1px dotted #fa571f 138 | .frame-note 139 | composes frame 140 | font-size 12px 141 | line-height 16px 142 | color #fa571f 143 | background rgba(248, 248, 248, 0.85) 144 | border-radius 2px 145 | padding 0 4px 146 | transform translate(-50%, -50%) 147 | 148 | .info 149 | background #f2f2f2 150 | height 100% 151 | overflow auto 152 | .empty 153 | font-size 16px 154 | color #999 155 | text-align center 156 | padding 14px 157 | .section-header 158 | color #666 159 | font-weight bold 160 | padding-left 8px 161 | font-size 12px 162 | line-height 27px 163 | background #f9f9f9 164 | box-shadow inset 0 -0.5px 0 0 #B8B8B8, inset 0 0.5px 0 0 #B8B8B8 165 | .previewer 166 | margin 16px 167 | background url(./bg.png) 168 | background-color white 169 | background-size 8px 170 | .code 171 | margin 0 172 | background white 173 | padding 8px; 174 | font-family: monospace; 175 | color: #666; 176 | overflow auto 177 | 178 | .spin 179 | &::before 180 | content '' 181 | display block 182 | width 32px 183 | height 32px 184 | border-radius 50% 185 | border-color #3884ff #3884ff transparent transparent 186 | border-style solid 187 | border-width 4px 188 | animation .5s linear 0s infinite normal none running rotate 189 | 190 | @keyframes rotate { 191 | from { 192 | transform: rotate(0) 193 | } 194 | 195 | to { 196 | transform: rotate(360deg) 197 | } 198 | } -------------------------------------------------------------------------------- /src/components/Document/LayerIndicator.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import {getObjectById} from "../../utils"; 4 | import styles from "./Document.styl"; 5 | function getp(a1, a2, b1, b2) { // a1 <= a2, b1 <= b2, select a, hover b 6 | if (b1 <= a1 && a2 <= b2) { 7 | return (a1 + a2) / 2; 8 | } else { 9 | return (b1 + b2) / 2; 10 | } 11 | } 12 | function f(a1, a2, b1, b2) {// a1 <= a2, b1 <= b2 13 | if (a1 > b1) { 14 | return f(b1, b2, a1, a2); 15 | } 16 | // a1 <= b1 17 | if (a2 < b1) { 18 | return [[a2, b1]] 19 | } 20 | if (a2 < b2) { 21 | return [[a1, b1], [a2, b2]]; 22 | } 23 | return [[a1, b1], [b2, a2]]; 24 | } 25 | function hLine(x1, x2, y, i) { 26 | return
34 |
39 |
41 | {`${Math.round(x2 - x1)}px`} 42 |
43 |
44 | } 45 | function vLine(y1, y2, x, i) { 46 | return
54 |
59 |
61 | {`${Math.round(y2 - y1)}px`} 62 |
63 |
64 | } 65 | function gao(a, b, o) {// select a, hover b, canvas o 66 | let [ax1, ax2, ay1, ay2] = [a.left - o.left, a.left + a.width - o.left, a.top - o.top, a.top + a.height - o.top]; 67 | let [bx1, bx2, by1, by2] = [b.left - o.left, b.left + b.width - o.left, b.top - o.top, b.top + b.height - o.top]; 68 | let x = getp(ax1, ax2, bx1, bx2); 69 | let y = getp(ay1, ay2, by1, by2); 70 | let hLines = f(ax1, ax2, bx1, bx2).filter(([a, b]) => b - a > 0.5).map(([left, right], i) => 71 | hLine(left, right, y, i)); 72 | let vLines = f(ay1, ay2, by1, by2).filter(([a, b]) => b - a > 0.5).map(([top, bottom], i) => 73 | vLine(top, bottom, x, i)); 74 | return hLines.concat(vLines); 75 | } 76 | 77 | export default class LayerIndicator extends React.PureComponent { 78 | static propTypes = { 79 | selectedLayer: PropTypes.string, 80 | hoveredLayer: PropTypes.string, 81 | }; 82 | 83 | render() { 84 | let {selectedLayer, hoveredLayer} = this.props; 85 | if (!selectedLayer && !hoveredLayer) { 86 | return false 87 | } 88 | let canvasDOM = document.getElementById('canvas'); 89 | let hoveredDOM = document.getElementById(hoveredLayer); 90 | let selectedlayerDOM = document.getElementById(selectedLayer); 91 | 92 | let r = selectedlayerDOM && selectedlayerDOM.getBoundingClientRect(); 93 | let b = hoveredDOM && hoveredDOM.getBoundingClientRect(); 94 | let o = canvasDOM.getBoundingClientRect(); 95 | let selectedmodel = getObjectById(selectedLayer); 96 | return ( 97 |
98 | {b &&
} 104 | {r && b && gao(r, b, o)} 105 | {r &&
111 |
118 |
125 | { (r && !b || selectedLayer === hoveredLayer) && 126 |
{`${Math.round(selectedmodel.frame.width)}px`} 130 |
131 | } 132 | { (r && !b || selectedLayer === hoveredLayer) && 133 |
{`${Math.round(selectedmodel.frame.height)}px`} 138 |
139 | } 140 |
} 141 | 142 |
143 | ); 144 | 145 | } 146 | } -------------------------------------------------------------------------------- /src/components/Document/LayerInfo.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import styles from "./Document.styl"; 4 | import hljs from 'highlight.js' 5 | import select from 'select' 6 | 7 | require('highlight.js/styles/color-brewer.css'); 8 | import {getReactCode} from '../../utils' 9 | const NodeTypes = { 10 | Element: 1, 11 | }; 12 | function removeIds(o) { 13 | if (o.nodeType === NodeTypes.Element) { 14 | o.removeAttribute('id'); 15 | if (o.nodeName !== 'SVG') { 16 | for (let child of o.childNodes) { 17 | removeIds(child); 18 | } 19 | } 20 | } 21 | } 22 | 23 | function getOuterHtmlCode(o, depth = 0, jsx = false) { 24 | let spaces = ''; 25 | for (let i = 0; i < depth; ++i) { 26 | spaces += ' '; 27 | } 28 | let tagName = o.tagName.toLowerCase(); 29 | switch (tagName) { 30 | case 'img': 31 | case 'path': 32 | case 'span': 33 | return `${spaces}${o.outerHTML}\n`; 34 | default: 35 | return `${spaces}<${tagName} style="${o.style.cssText}">\n${Array.prototype.map.call(o.childNodes, child => getOuterHtmlCode(child, depth + 1)).join('')}${spaces}\n` 36 | } 37 | } 38 | export default class LayerInfo extends React.Component { 39 | 40 | static propTypes = { 41 | layer: PropTypes.string, 42 | }; 43 | 44 | componentWillMount() { 45 | this.state = {codeMode: 'react'}; 46 | } 47 | 48 | selectCodeMode = (e) => { 49 | this.setState({codeMode: e.target.value}); 50 | }; 51 | 52 | render() { 53 | let {layer} = this.props; 54 | if (!layer) { 55 | return
56 |
Select a layer
57 |
58 | } else { 59 | let _o = document.getElementById(layer); 60 | let o = _o.cloneNode(); 61 | o.innerHTML = _o.innerHTML; 62 | removeIds(o); 63 | o.style.cssText = o.style.cssText.replace(/top:[^;]*;/, '').replace(/left:[^;]*;/, ''); 64 | let ratio = _o.clientWidth > 208 ? 208 / _o.clientWidth : 1; 65 | 66 | return
67 |
68 |
Preview
69 |
70 |
79 |
80 |
81 |
82 |
CSS (outer)
83 |
 85 |         
86 |
87 | 101 | {this.state.codeMode === 'html' ? 102 |
104 |           
: 105 |
107 |           
108 | } 109 |
110 |
; 111 | } 112 | } 113 | } -------------------------------------------------------------------------------- /src/components/Document/LayerSelector.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import classnames from "classnames"; 3 | import folderImg from "./folder.png"; 4 | import styles from "./Document.styl"; 5 | let folder = ; 6 | let symbol = 8 | 11 | ; 12 | let bitmap = 13 | 15 | 18 | ; 19 | let eye = 20 | 21 | 23 | 24 | ; 25 | let lock = 26 | 28 | ; 29 | export default class LayerSelector extends React.PureComponent { 30 | constructor(props) { 31 | super(props); 32 | } 33 | 34 | toggleExpand = (model, e) => { 35 | if (model.layerListExpandedType === 1) { 36 | model.layerListExpandedType = 0; 37 | } else { 38 | model.layerListExpandedType = 1; 39 | } 40 | e && e.stopPropagation(); 41 | e && e.preventDefault(); 42 | 43 | this.forceUpdate(); 44 | return false; 45 | }; 46 | 47 | treeview = (model, depth = 0, hasMask) => { 48 | let {selectedLayer, hoveredLayer, onSelect, onMouseEnter, onMouseLeave} = this.props; 49 | 50 | let canExpanded = model.layers && model._class !== 'shapeGroup' && ( 51 | model.layers.length > 0 || 52 | model._class === 'artboard' 53 | ); 54 | let isExpanded = model.layerListExpandedType !== 1; 55 | 56 | let layerMaskFlag = []; 57 | if (canExpanded && isExpanded) { 58 | let underLyingMask = false; 59 | for (let layer of model.layers) { 60 | if (layer.hasClippingMask) { 61 | underLyingMask = true; 62 | layerMaskFlag.push(false); 63 | continue; 64 | } 65 | if (layer.shouldBreakMaskChain) { 66 | underLyingMask = false; 67 | } 68 | layerMaskFlag.push(underLyingMask); 69 | } 70 | layerMaskFlag.reverse(); 71 | } 72 | ++this.idx; 73 | 74 | return
75 | onSelect(model.do_objectID)} 84 | onMouseEnter={() => onMouseEnter(model.do_objectID)} 85 | onMouseLeave={() => onMouseLeave(model.do_objectID)} 86 | > 87 | 111 | }; 112 | 113 | render() { 114 | let {model, selectedLayer, hoveredLayer, onSelect, onMouseEnter, onMouseLeave, style, ...props} = this.props; 115 | this.idx = 0; 116 | return ( 117 |
124 |
130 | {model.name} 131 |
132 |
136 | { 137 | model.layers.map((artboard) => 138 | this.treeview(artboard) 139 | ).reverse() 140 | } 141 |
142 |
); 143 | } 144 | } -------------------------------------------------------------------------------- /src/components/Document/PageSelector.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | 4 | export default class PageSelector extends React.PureComponent { 5 | render() { 6 | let {model, selectedPage, onSelect, style, ...props} = this.props; 7 | let {pagesAndArtboards} = model; 8 | return ( 9 |
16 |
Pages 21 |
22 |
23 | { 24 | Object.keys(pagesAndArtboards).reverse().map((id, i) => 25 |
onSelect(id)}>{pagesAndArtboards[id].name}
34 | ) 35 | } 36 |
37 |
); 38 | } 39 | } -------------------------------------------------------------------------------- /src/components/Document/PageSelector/__fixtures__/default.js: -------------------------------------------------------------------------------- 1 | export default { 2 | props: { 3 | selectedPage: "9B2D6A75-D2C0-40F3-88AB-AE4BD625E520", 4 | model: { 5 | "9B2D6A75-D2C0-40F3-88AB-AE4BD625E520": { 6 | "name": "Page 1", 7 | "artboards": { 8 | "A8E5EE18-1107-4A18-BC0C-BE7EAD0135DD": { 9 | "name": "Artboard" 10 | }, 11 | "EE2328CD-533A-494C-B438-085002962A3B": { 12 | "name": "Menu" 13 | }, 14 | "B8ECDD04-C5B6-496B-B640-7743425317F7": { 15 | "name": "Detail View" 16 | }, 17 | "98555297-4575-40EC-AB67-B1C3918F163C": { 18 | "name": "History" 19 | }, 20 | "0D7CFF39-0795-427A-A195-749BCFB97FA4": { 21 | "name": "Timeline" 22 | }, 23 | "10E1A27E-C298-40A5-8547-A62A031514C6": { 24 | "name": "Friends" 25 | }, 26 | "8FC9E048-A8C7-4D6D-9295-ABF623D28EF7": { 27 | "name": "Artboard 2" 28 | }, 29 | "DDD74CEB-8B48-487D-92D8-4FD3BDB04411": { 30 | "name": "Profile" 31 | } 32 | } 33 | }, 34 | "FE9FC96B-0BC2-4F9D-9103-291E81FFBC89": { 35 | "name": "Test", 36 | "artboards": { 37 | "67721912-B3C8-4E42-ADD7-6B1DAA198C25": { 38 | "name": "Artboard 2" 39 | }, 40 | "15B11D08-4924-4C01-B238-D75146184310": { 41 | "name": "xx" 42 | }, 43 | "F0A42559-1D43-4F22-8343-4281CB57F7C1": { 44 | "name": "Artboard" 45 | } 46 | } 47 | }, 48 | "1C01EF08-06C2-469A-8EB7-0BF12367FF6E": { 49 | "name": "Symbols", 50 | "artboards": { 51 | "32FD6548-E53C-4615-88E6-117FBDACCF42": { 52 | "name": "Background" 53 | }, 54 | "156E2FF8-7889-431D-82F1-9021CE955F5E": { 55 | "name": "123" 56 | }, 57 | "94D9121A-2939-4CDA-8871-C4934FF48F23": { 58 | "name": "Settings + Settings" 59 | }, 60 | "3D529B09-3AA9-41FF-9675-1C6895CC20A9": { 61 | "name": "Artboard" 62 | }, 63 | "3C69524A-C8CD-4847-B6C1-62807F339A48": { 64 | "name": "Status Bar" 65 | }, 66 | "72C43561-2764-4D7C-8C6A-5A4B3FED4727": { 67 | "name": "Time" 68 | } 69 | } 70 | } 71 | } 72 | } 73 | } -------------------------------------------------------------------------------- /src/components/Document/__fixtures__/Fitness App.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zjuasmn/sketch-react/8582552e8538cdf507f6899724d4a9124028d394/src/components/Document/__fixtures__/Fitness App.sketch -------------------------------------------------------------------------------- /src/components/Document/__fixtures__/document.js: -------------------------------------------------------------------------------- 1 | // let fileUrl = require('./ui-video-simple-john-hansen.sketch'); 2 | let fileUrl = require('./Fitness App.sketch'); 3 | 4 | export default {props: {blob: fetch(fileUrl).then(response => response.blob())}} -------------------------------------------------------------------------------- /src/components/Document/__fixtures__/ui-video-simple-john-hansen.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zjuasmn/sketch-react/8582552e8538cdf507f6899724d4a9124028d394/src/components/Document/__fixtures__/ui-video-simple-john-hansen.sketch -------------------------------------------------------------------------------- /src/components/Document/bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zjuasmn/sketch-react/8582552e8538cdf507f6899724d4a9124028d394/src/components/Document/bg.png -------------------------------------------------------------------------------- /src/components/Document/folder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zjuasmn/sketch-react/8582552e8538cdf507f6899724d4a9124028d394/src/components/Document/folder.png -------------------------------------------------------------------------------- /src/components/Layer/Artboard.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Group from './Group' 3 | 4 | export default class Artboard extends Group { 5 | render() { 6 | let {style, ...props} = this.props; 7 | return ; 14 | } 15 | } -------------------------------------------------------------------------------- /src/components/Layer/Bitmap.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import classnames from 'classnames' 3 | 4 | 5 | export default class Bitmap extends React.Component { 6 | constructor(props) { 7 | super(props); 8 | this.state = {}; 9 | } 10 | 11 | componentWillMount() { 12 | this.props.model.image.getInstance().then(url => { 13 | this.setState({url}); 14 | }); 15 | } 16 | 17 | render() { 18 | let {model, style, ...props} = this.props; 19 | let {frame} = model; 20 | return 33 | } 34 | } -------------------------------------------------------------------------------- /src/components/Layer/Group.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | function isMaskGroup(model) { 4 | if (!(model.layers && model.layers.length > 1 && model.layers[0].hasClippingMask)) { 5 | return false; 6 | } 7 | for (let layer of model.layers) { 8 | if (layer.shouldBreakMaskChain) { 9 | return false; 10 | } 11 | } 12 | let mask = model.layers[0]; 13 | return mask.isSimple && mask.isSimple(); 14 | } 15 | 16 | export default class Group extends React.Component { 17 | render() { 18 | let {model, children, style, ...props} = this.props; 19 | let {frame} = model; 20 | 21 | 22 | let layoutStyles = model.getLayoutStyles(); 23 | style = { 24 | position: 'absolute', 25 | height: frame.height, 26 | width: frame.width, 27 | top: frame.y, 28 | left: frame.x, 29 | ...model.style.toStyle(model), 30 | ...style, 31 | ...layoutStyles[model['do_objectID']] 32 | }; 33 | 34 | children = React.Children.map(children, child => React.cloneElement(child, {style: layoutStyles[child.props.model['do_objectID']]})) 35 | 36 | if (!isMaskGroup(model)) { 37 | return ( 38 |
39 | {children} 40 |
); 41 | } 42 | let mask = model.layers[0]; 43 | let maskStyle = mask.style.toStyle(mask); 44 | 45 | return ( 46 |
{ 55 | mask.frame.y || mask.frame.x ? 56 |
61 | {children} 62 |
63 | : 64 | children 65 | } 66 | 67 |
); 68 | 69 | } 70 | } -------------------------------------------------------------------------------- /src/components/Layer/Layer.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Page from "./Page"; 3 | import Artboard from "./Artboard"; 4 | import ShapeGroup from "./ShapeGroup"; 5 | import Group from "./Group"; 6 | import SymbolInstance from "./Symbol"; 7 | import Text from "./Text"; 8 | import Bitmap from "./Bitmap"; 9 | import {getSymbolById} from "../../utils"; 10 | import PropTypes from "prop-types"; 11 | 12 | class PlaceHolder extends React.Component { 13 | render() { 14 | let {model} = this.props; 15 | 16 | let {frame} = model; 17 | if (!frame) { 18 | debugger 19 | } 20 | return
{model._class}
; 29 | } 30 | } 31 | function getComp(_class) { 32 | switch (_class) { 33 | case 'mask': 34 | return Mask; 35 | case 'page': 36 | return Page; 37 | case 'artboard': 38 | case 'symbolMaster': 39 | return Artboard; 40 | case `shapeGroup`: 41 | return ShapeGroup; 42 | case `group`: 43 | return Group; 44 | case `text`: 45 | return Text; 46 | case `symbolInstance`: 47 | return SymbolInstance; 48 | case `bitmap`: 49 | return Bitmap; 50 | default: 51 | return PlaceHolder; 52 | } 53 | } 54 | class Mask extends React.Component { 55 | render() { 56 | let {model, children, ...props} = this.props; 57 | let {frame} = model; 58 | 59 | let style = { 60 | position: 'absolute', 61 | overflow: 'hidden', 62 | height: frame.height, 63 | width: frame.width, 64 | top: frame.y, 65 | left: frame.x, 66 | }; 67 | return ( 68 |
69 | {children} 70 |
); 71 | } 72 | } 73 | export default class Layer extends React.PureComponent { 74 | static contextTypes = { 75 | onClick: PropTypes.func, 76 | onMouseEnter: PropTypes.func, 77 | onMouseLeave: PropTypes.func, 78 | }; 79 | onClick = (e) => { 80 | if (this.props.inSymbol) { 81 | return; 82 | } 83 | this.context.onClick(this.props.model.do_objectID); 84 | e.stopPropagation(); 85 | }; 86 | onMouseEnter = (e) => { 87 | if (this.props.inSymbol) { 88 | return; 89 | } 90 | this.context.onMouseEnter(this.props.model.do_objectID); 91 | // e.stopPropagation(); 92 | }; 93 | onMouseLeave = (e) => { 94 | if (this.props.inSymbol) { 95 | return; 96 | } 97 | this.context.onMouseLeave(this.props.model.do_objectID); 98 | // e.stopPropagation(); 99 | }; 100 | 101 | render() { 102 | let {model, inSymbol, ...props} = this.props; 103 | let {layers, _class} = model; 104 | let Comp = getComp(_class); 105 | if (Comp === SymbolInstance) { 106 | layers = getSymbolById(model['symbolID']).layers; 107 | } 108 | return 115 | {layers && layers.map(layer => )} 117 | 118 | } 119 | } -------------------------------------------------------------------------------- /src/components/Layer/Page.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import classnames from 'classnames' 3 | import Artboard from "./Artboard"; 4 | 5 | export default class Page extends React.Component { 6 | constructor(props) { 7 | super(props); 8 | } 9 | 10 | render() { 11 | let {model, children, ...props} = this.props; 12 | 13 | return ( 14 |
17 | 18 | {children} 19 | 20 | {model.layers.map(artboard =>
{artboard.name}
)} 30 | 31 |
); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/components/Layer/ShapeGroup.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {BooleanOperation} from 'sketch-constants' 3 | 4 | export default class ShapeGroup extends React.Component { 5 | 6 | render() { 7 | let {model, children, style, ...props} = this.props; 8 | let {frame} = model; 9 | if (model.isSimple()) { 10 | style = { 11 | ...model.style.toStyle(model), 12 | ...style 13 | }; 14 | return
{ 25 | !('background' in style) && model.style['fills'] && model.style['fills'] 26 | .filter(fill => fill['isEnabled']) 27 | .map((fill, i) =>
) 38 | }
39 | } else { 40 | let ds = []; 41 | let d = ''; 42 | for (let layer of model.layers) { 43 | if (layer['isVisible']) { 44 | if (layer['booleanOperation'] === BooleanOperation.Union) { 45 | ds.push(d); 46 | d = ''; 47 | } 48 | d += layer.toD(); 49 | } 50 | } 51 | ds.push(d); 52 | return 64 | {ds.map((d, i) => )} 65 | ; 66 | } 67 | 68 | } 69 | } -------------------------------------------------------------------------------- /src/components/Layer/Symbol.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | 4 | export default class Symbol extends React.Component { 5 | render() { 6 | let {model: {frame}, style, ...props} = this.props; 7 | style = { 8 | position: 'absolute', 9 | height: frame.height, 10 | width: frame.width, 11 | top: frame.y, 12 | left: frame.x, 13 | ...style, 14 | }; 15 | return
; 16 | } 17 | } -------------------------------------------------------------------------------- /src/components/Layer/Text.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import classnames from 'classnames' 3 | 4 | export default class Text extends React.Component { 5 | render() { 6 | let {model, style, ...props} = this.props; 7 | let {frame} = model; 8 | 9 | return {model.attributedString.archivedAttributedString.NSString}; 20 | } 21 | } -------------------------------------------------------------------------------- /src/components/Layer/__fixtures__/layer.js: -------------------------------------------------------------------------------- 1 | // let json = require('../../../../playground/9B2D6A75-D2C0-40F3-88AB-AE4BD625E520.json'); 2 | // import {parse} from '../../../utils' 3 | // 4 | // export default {props:{model:parse(json)}} -------------------------------------------------------------------------------- /src/components/Site/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Document from '../Document/Document' 3 | import "./index.css"; 4 | import fetch from 'isomorphic-fetch' 5 | import Loading from './Loading' 6 | import qs from 'querystring' 7 | 8 | // https://github.com/github/fetch/issues/89#issuecomment-256610849 9 | function futch(url, opts = {}, onProgress) { 10 | return new Promise((res, rej) => { 11 | let xhr = new XMLHttpRequest(); 12 | xhr.responseType = "blob"; 13 | xhr.open(opts.method || 'get', url); 14 | for (let k in opts.headers || {}) 15 | xhr.setRequestHeader(k, opts.headers[k]); 16 | xhr.onload = e => res(e.target); 17 | xhr.onerror = rej; 18 | // event.loaded / event.total * 100 ; //event.lengthComputable 19 | if (onProgress) { 20 | xhr.upload && xhr.upload.addEventListener('progress', onProgress); 21 | xhr.addEventListener('progress', onProgress); 22 | } 23 | xhr.send(opts.body); 24 | }); 25 | } 26 | 27 | class Header extends React.PureComponent { 28 | onClickTitle = () => { 29 | this.fileInput && this.fileInput.click(); 30 | }; 31 | gao = (input) => { 32 | this.fileInput = input; 33 | }; 34 | 35 | render() { 36 | let {file, filename = '', onSelectFile} = this.props; 37 | return
38 | Sketch React 39 | 40 | { 41 | file && 42 | {filename.replace(/\.sketch$/, '')} 43 | 44 | 45 | } 46 | {!file &&
} 47 | 55 |
56 | } 57 | } 58 | const simpleFiles = [ 59 | 'https://zjuasmn.github.io/sketch-react/images/ui-video-simple-john-hansen.sketch', 60 | 'https://zjuasmn.github.io/sketch-react/images/Fitness%20App.sketch', 61 | ]; 62 | function getName(url) { 63 | return decodeURIComponent(url.substring(url.lastIndexOf('/') + 1)); 64 | } 65 | export default class App extends React.Component { 66 | constructor(props) { 67 | super(props); 68 | this.state = {}; 69 | } 70 | 71 | onSelectFile = (e) => { 72 | if (!e) { 73 | return this.setState({file: null, filename: ''}); 74 | } 75 | e.preventDefault(); 76 | let file; 77 | if (e.dataTransfer) { 78 | file = e.dataTransfer.files[0]; 79 | } else if (e.target.files.length) { 80 | file = e.target.files[0]; 81 | } 82 | if (file) { 83 | 84 | this.setState({file, filename: file.name}); 85 | } 86 | }; 87 | onClickButton = () => { 88 | this.fileInput && this.fileInput.click(); 89 | }; 90 | gao = (input) => { 91 | this.fileInput = input; 92 | }; 93 | selectFileFromURL = (url) => { 94 | this.setState({file: fetch(url).then(r => r.blob()), filename: getName(url)}) 95 | }; 96 | 97 | componentWillMount() { 98 | console.log('mount', this.state); 99 | let url; 100 | if (this.props.__url) {// for test 101 | url = this.props.__url 102 | } else { 103 | let queryObj = qs.parse(location.search.substr(1)); 104 | if ('url' in queryObj) { 105 | url = queryObj['url']; 106 | } 107 | } 108 | if (url) { 109 | this.setState({loading: true, filename: getName(url)}); 110 | futch(url, {}, (progress) => { 111 | this.setState({progress}) 112 | }) 113 | .then(e => { 114 | if (200 <= e.status && e.status < 300) { 115 | this.setState({loading: false, file: e.response}); 116 | } else { 117 | throw new Error('fail to fetch file from this url!'); 118 | 119 | } 120 | }) 121 | .catch(e => { 122 | this.setState({loading: false}); 123 | if(e.message) { 124 | alert(e.message); 125 | }else{ 126 | alert(`Fail to load from this url. Does it allow cross domain access?`); 127 | } 128 | location.search = ''; 129 | }); 130 | } 131 | } 132 | 133 | render() { 134 | let {file, filename, loading, progress} = this.state; 135 | 136 | return
137 |
138 | { loading && 139 | location.search = ''}/>} 146 | {!loading && !file &&
147 |

From Local File

148 |
{ 150 | e.preventDefault(); 151 | }} 152 | onDragLeave={(e) => { 153 | e.preventDefault(); 154 | }} 155 | onDragOver={(e) => { 156 | e.preventDefault(); 157 | }} 158 | onDrop={this.onSelectFile} 159 | // onDropCapture={(e) => { 160 | // e.preventDefault(); 161 | // console.log(e); 162 | // debugger 163 | // }} 164 | > 165 |
Drop .sketch(v43+) file here
166 | Or select from computer 168 |
169 | 181 | {/**/} 189 |
190 |

Sample Files

191 |
192 | {simpleFiles.map(url => 193 | 197 | )} 198 |
199 |
200 |
} 201 | {!loading && file && } 202 |
203 | } 204 | } -------------------------------------------------------------------------------- /src/components/Site/App/__fixtures__/default.js: -------------------------------------------------------------------------------- 1 | export default { 2 | props: { 3 | // __url: 'https://zjuasmn.github.io/sketch-react/images/Fitness%20App.sketch', 4 | } 5 | } -------------------------------------------------------------------------------- /src/components/Site/Loading.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import prettyBytes from '../../utils/pretty-bytes' 3 | 4 | export default class Loading extends React.Component { 5 | render() { 6 | let {filename, progress = {total: 0, loaded: 0}, onCancel, style, ...props} = this.props; 7 | let ratio = progress.total ? progress.loaded / progress.total : 0; 8 | return ( 9 |
15 |

{filename}

21 |
27 |
34 |
35 | {prettyBytes(progress.total)} 46 | {`${(ratio * 100).toFixed(2)}%`} 56 | 73 | Cancel 74 | 75 |
76 | ); 77 | } 78 | } -------------------------------------------------------------------------------- /src/components/Site/Loading/__fixtures__/default.js: -------------------------------------------------------------------------------- 1 | export default { 2 | props: { 3 | progress: { 4 | total: 15461421, 5 | loaded: 2302000, 6 | }, 7 | onCancel: () => alert('cancel click'), 8 | filename: 'sample.sketch', 9 | } 10 | } -------------------------------------------------------------------------------- /src/components/Site/index.css: -------------------------------------------------------------------------------- 1 | html, body, #root { 2 | height: 100%; 3 | margin: 0; 4 | padding: 0; 5 | } 6 | body{ 7 | font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; 8 | } 9 | .header { 10 | height: 32px; 11 | display: flex; 12 | align-items: center; 13 | padding: 0 16px; 14 | box-shadow: 0 1px 0 0 #b2b2b2; 15 | } 16 | 17 | .header a { 18 | color: #666; 19 | text-decoration: none; 20 | /*cursor: pointer;*/ 21 | } 22 | 23 | .header a:hover { 24 | color: #333; 25 | } 26 | 27 | .flex { 28 | flex: 1; 29 | } 30 | 31 | .title { 32 | text-decoration: none; 33 | font-size: 18px; 34 | color: #666; 35 | width: 120px; 36 | } 37 | 38 | .filename { 39 | flex: 1; 40 | text-align: center; 41 | color: #666; 42 | font-size: 16px; 43 | 44 | } 45 | 46 | .title:hover { 47 | color: #333; 48 | } 49 | 50 | .icon { 51 | fill: #999; 52 | 53 | } 54 | 55 | .actions { 56 | width: 120px; 57 | } 58 | 59 | .icon-link { 60 | display: block; 61 | float: right; 62 | height: 16px; 63 | } 64 | 65 | .icon-link:hover .icon { 66 | fill: #333; 67 | } 68 | 69 | .upload-area { 70 | margin: 64px auto 0; 71 | height: 180px; 72 | width: 320px; 73 | padding: 16px; 74 | border: 2px dashed #666; 75 | display: flex; 76 | flex-direction: column; 77 | } 78 | .upload-area .desc{ 79 | flex: 1; 80 | text-align: center; 81 | padding-top: 32px; 82 | font-size: 16px; 83 | color: #666; 84 | } 85 | .upload-button { 86 | cursor: pointer; 87 | display: block; 88 | background: #3884ff; 89 | color: white; 90 | text-align: center; 91 | padding: 4px 16px; 92 | border-radius: 3px; 93 | font-size: 14px; 94 | line-height: 24px; 95 | } 96 | 97 | .sample { 98 | margin: 16px auto; 99 | text-align: center; 100 | } 101 | .sample-header{ 102 | color: #666; 103 | } 104 | .sample-link{ 105 | cursor: pointer; 106 | 107 | font-size: 16px; 108 | line-height: 24px; 109 | margin-top: 8px; 110 | } 111 | .sample-link a{ 112 | color: #3884ff; 113 | text-decoration: underline; 114 | } -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Document from './components/Document/Document' 2 | 3 | export default Document; -------------------------------------------------------------------------------- /src/models/index.js: -------------------------------------------------------------------------------- 1 | import "babel-polyfill"; 2 | import {BlendingMode, CurvePointMode, FillType} from "sketch-constants"; 3 | import kebabCase from "lodash/kebabCase"; 4 | 5 | 6 | export class Page { 7 | static _class = 'page'; 8 | } 9 | 10 | export class Rect { 11 | static _class = 'rect'; 12 | 13 | set x(_x) { 14 | this._x = _x; 15 | } 16 | 17 | get x() { 18 | return Math.round(this._x); 19 | } 20 | 21 | set y(_y) { 22 | this._y = _y; 23 | } 24 | 25 | get y() { 26 | return Math.round(this._y); 27 | } 28 | 29 | set width(_width) { 30 | this._width = _width; 31 | } 32 | 33 | get width() { 34 | return Math.round(this._width); 35 | } 36 | 37 | set height(_height) { 38 | this._height = _height; 39 | } 40 | 41 | get height() { 42 | return Math.round(this._height); 43 | } 44 | } 45 | export class ShapeGroup { 46 | static _class = 'shapeGroup'; 47 | 48 | isSimple() { 49 | let {layers} = this; 50 | if (layers && layers.length === 1 && !layers[0]['edited']) { 51 | if (layers[0] instanceof Oval && this.frame.width === this.frame.height) { 52 | return true; 53 | } 54 | if (layers[0] instanceof Rectangle) { 55 | return true; 56 | } 57 | } 58 | return false; 59 | } 60 | } 61 | export class Group { 62 | static _class = 'group'; 63 | 64 | 65 | wrapperFrame(layers) { 66 | let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; 67 | for (let layer of layers) { 68 | minX = Math.min(minX, layer.frame.x); 69 | minY = Math.min(minY, layer.frame.y); 70 | maxX = Math.max(maxX, layer.frame.x + layer.frame.width); 71 | maxY = Math.max(maxY, layer.frame.y + layer.frame.height); 72 | } 73 | minX = Math.max(minX, 0); 74 | maxX = Math.min(maxX, this.frame.width); 75 | minY = Math.max(minY, 0); 76 | maxY = Math.min(maxY, this.frame.height); 77 | 78 | return {x: minX, y: minY, width: maxX - minX, height: maxY - minY}; 79 | } 80 | 81 | getLayoutStyles() { 82 | let {layers} = this; 83 | 84 | let visibleLayers = layers.filter(layer => layer.isVisible); 85 | if (!visibleLayers.length) { 86 | return {}; 87 | } 88 | let styles = {[this['do_objectID']]: {}}; 89 | visibleLayers.forEach(layer => { 90 | styles[layer['do_objectID']] = {}; 91 | }); 92 | // single layer 93 | if (visibleLayers.length === 1) { 94 | return { 95 | [visibleLayers[0]['do_objectID']]: { 96 | position: 'relative', 97 | left: undefined, 98 | top: undefined, 99 | height: '100%', 100 | width: undefined, 101 | } 102 | }; 103 | } 104 | // check bg layer 105 | let {y, x, width, height} = visibleLayers[0].frame; 106 | 107 | let hasBgLayer = false; 108 | if (y === 0 && x === 0 && width === this.frame.width && height === this.frame.height) { 109 | hasBgLayer = true; 110 | } 111 | if (hasBgLayer) { 112 | styles[visibleLayers[0]['do_objectID']] = { 113 | position: 'absolute', 114 | left: 0, 115 | top: 0, 116 | height: '100%', 117 | width: '100%', 118 | }; 119 | visibleLayers.splice(0, 1); 120 | } 121 | // for column layout 122 | // if (this['do_objectID'] === "9FFA94D8-633C-44A0-9DDA-F8B2A2EE970C") { 123 | // debugger 124 | // } 125 | let innerFrame = this.wrapperFrame(visibleLayers); 126 | if (innerFrame.x !== 0 || innerFrame.y !== 0 || innerFrame.width !== this.frame.width || innerFrame.height !== this.frame.height) { 127 | Object.assign(styles[this['do_objectID']], { 128 | boxSizing: 'border-box', 129 | padding: `${innerFrame.y}px ${this.frame.width - innerFrame.width - innerFrame.x}px ${this.frame.height - innerFrame.height - innerFrame.y}px ${innerFrame.x}px`, 130 | }); 131 | } 132 | // visibleLayers.sort((a, b) => a.frame.y - b.frame.y); 133 | 134 | let isColumn = visibleLayers.every((layer, i) => i === 0 || visibleLayers[i - 1].frame.y + visibleLayers[i - 1].frame.height <= visibleLayers[i].frame.y); 135 | if (isColumn) { 136 | visibleLayers.forEach((layer, i) => { 137 | let style = styles[layer['do_objectID']]; 138 | Object.assign(style, { 139 | position: 'relative', 140 | left: undefined, 141 | top: undefined, 142 | }); 143 | let leftGap = layer.frame.x - innerFrame.x; 144 | let rightGap = innerFrame.x + innerFrame.width - layer.frame.x - layer.frame.width; 145 | if (leftGap === rightGap) { 146 | if (leftGap !== 0) { 147 | Object.assign(style, { 148 | marginLeft: 'auto', 149 | marginHeight: 'auto', 150 | }); 151 | } else { 152 | Object.assign(style, { 153 | width: undefined, 154 | }); 155 | } 156 | } else { 157 | if (leftGap) { 158 | Object.assign(style, { 159 | marginLeft: leftGap, 160 | }); 161 | } 162 | } 163 | if (i >= 1) { 164 | let topGap = layer.frame.y - visibleLayers[i - 1].frame.y - visibleLayers[i - 1].frame.height; 165 | Object.assign(style, { 166 | marginTop: topGap ? topGap : undefined, 167 | }); 168 | } 169 | }); 170 | return {styles}; 171 | } 172 | 173 | // TODO: row layout 174 | // TODO: grid layout 175 | return {styles}; 176 | } 177 | } 178 | export class Artboard extends Group { 179 | static _class = 'artboard'; 180 | } 181 | export class SymbolMaster extends Artboard { 182 | static _class = 'symbolMaster'; 183 | } 184 | function f2i(f) { 185 | return Math.round(f * 255) || 0; 186 | } 187 | 188 | function f2x(f) { 189 | let s = (Math.round(f * 255) || 0).toString(16); 190 | if (s.length === 1) { 191 | s = '0' + s; 192 | } 193 | return s; 194 | } 195 | 196 | export class Color { 197 | static _class = 'color'; 198 | 199 | toString(context) { 200 | let opacity = context && 'opacity' in context ? context['opacity'] : 1; 201 | let alpha = this.alpha === undefined ? 1 : this.alpha; 202 | if (alpha === 1) { 203 | return `#${f2x(this.red)}${f2x(this.green)}${f2x(this.blue)}` 204 | } 205 | return `rgba(${f2i(this.red)},${f2i(this.green)},${f2i(this.blue)},${alpha * opacity})` 206 | } 207 | } 208 | 209 | export class Gradient { 210 | static _class = 'gradient'; 211 | 212 | toString(model) { 213 | switch (this.gradientType) { 214 | case 0: // linear-gradient 215 | let {x: x1, y: y1} = s2p(this.from); 216 | let {x: x2, y: y2} = s2p(this.to); 217 | x1 *= model.frame.width; 218 | x2 *= model.frame.width; 219 | y1 *= model.frame.height; 220 | y2 *= model.frame.height; 221 | let angle = 90 + Math.atan2(y2 - y1, x2 - x1) * 180 / Math.PI; 222 | return `linear-gradient(${angle}deg, ${this.stops.join(', ')})`; 223 | default: 224 | return ``; 225 | } 226 | } 227 | } 228 | 229 | export class GradientStop { 230 | static _class = 'gradientStop'; 231 | 232 | toString() { 233 | return `${this.color} ${this.position * 100}%` 234 | } 235 | } 236 | 237 | export class Style { 238 | static _class = 'style'; 239 | 240 | toStyle(model = {}, isSvg = false) { 241 | let ret = {}; 242 | // Lock, ClickThrough 243 | if (model.isLocked) { 244 | Object.assign(ret, {pointerEvent: 'none'}); 245 | } else { 246 | if (!model.hasClickThrough) { 247 | Object.assign(ret, {pointerEvent: 'auto'}); 248 | } 249 | } 250 | // Visible 251 | if (!model.isVisible) { 252 | Object.assign(ret, {display: 'none'}); 253 | } 254 | // Rotation, FlippedHorizontal, FlippedVertical 255 | if (model.rotation || model.isFlippedHorizontal || model.isFlippedVertical) { 256 | let transformString = ''; 257 | if (model.rotation) { 258 | transformString += ` rotate(${-model.rotation}deg)` 259 | } 260 | if (model.isFlippedHorizontal || model.isFlippedVertical) { 261 | let a = model.isFlippedHorizontal ? -1 : 1; 262 | let d = model.isFlippedVertical ? -1 : 1; 263 | transformString += ` matrix(${a}, 0, 0, ${d}, 0, 0)` 264 | } 265 | Object.assign(ret, {transform: transformString}); 266 | } 267 | if (this['borders']) { // Only accept first border 268 | let borders = this['borders']; 269 | Object.assign(ret, {boxSizing: `border-box`}); 270 | let border = borders.filter(border => border['isEnabled']).reverse()[0]; 271 | if (border) { 272 | Object.assign(ret, border.toStyle(isSvg)); 273 | } 274 | } 275 | let hasContextStyle = false; 276 | if (this['contextSettings']) { 277 | let {opacity, blendMode} = this['contextSettings']; 278 | if (opacity !== 1) { 279 | hasContextStyle = true; 280 | Object.assign(ret, {opacity: this['contextSettings'].opacity}); 281 | } 282 | if (blendMode !== BlendingMode.Normal) { 283 | hasContextStyle = true; 284 | Object.assign(ret, {mixBlendMode: getBlendModeString(this['contextSettings'].blendMode)}); 285 | } 286 | } 287 | // Complex shape 288 | if (isSvg) { 289 | Object.assign(ret, {fillRule: 'evenodd', fill: 'none'}); 290 | if (this['fills']) { 291 | let fills = this['fills'].filter(fill => fill['isEnabled']).reverse(); 292 | if (fills.length) { 293 | Object.assign(ret, {fill: fills[0].color.toString()}); 294 | } 295 | } 296 | return ret; 297 | } 298 | // Simple shape 299 | if (model instanceof ShapeGroup && model.isSimple()) { 300 | if (model.layers[0] instanceof Oval) { 301 | Object.assign(ret, {borderRadius: '50%'}); 302 | } 303 | if (model.layers[0] instanceof Rectangle) { 304 | let path = model.layers[0].path; 305 | Object.assign(ret, {borderRadius: `${[0, 2, 1, 3].map(i => path.points[0]['cornerRadius'] + 'px').join(' ')}`}); 306 | } 307 | } 308 | // Shadow, innerShadow 309 | let shadowList = []; 310 | if (this['shadows']) { 311 | shadowList = shadowList.concat(this['shadows'].filter(shadow => shadow['isEnabled'])); 312 | } 313 | if (this['innerShadows']) { 314 | shadowList = shadowList.concat(this['innerShadows'].filter(shadow => shadow['isEnabled'])); 315 | } 316 | if (shadowList.length) { 317 | Object.assign(ret, {boxShadow: shadowList.map(s => s.toString()).join(', ')}); 318 | } 319 | // Fills (simple) 320 | if (model instanceof ShapeGroup && !hasContextStyle && this['fills']) { 321 | let fills = this['fills'].filter(fill => fill['isEnabled']); 322 | if (fills.length && fills.every(fill => fill.blendMode === getBlendModeString(BlendingMode.Normal))) { 323 | Object.assign(ret, {background: fills.map(fill => fill.toString(model)).join(', ')}) 324 | } 325 | } 326 | return ret; 327 | } 328 | } 329 | function getBlendModeString(blendMode) { 330 | for (let mode in BlendingMode) { 331 | if (BlendingMode[mode] === blendMode) { 332 | return kebabCase(mode); 333 | } 334 | } 335 | return ''; 336 | } 337 | export class GraphicsContextSettings { 338 | static _class = 'graphicsContextSettings'; 339 | } 340 | export class Fill { 341 | static _class = 'fill'; 342 | 343 | get blendMode() { 344 | let context = this['contextSettings']; 345 | if (!context) { 346 | context = {blendMode: BlendingMode.Normal}; 347 | } 348 | return getBlendModeString(context.blendMode); 349 | } 350 | 351 | toString(model) { 352 | switch (this.fillType) { 353 | case FillType.Solid: // flat color 354 | let c = this.color.toString(); 355 | return `linear-gradient(0deg, ${c},${c})`; 356 | case FillType.Gradient:// gradient 357 | return this.gradient.toString(model); 358 | default: 359 | return ''; 360 | } 361 | } 362 | 363 | toStyle(model) { 364 | switch (this.fillType) { 365 | case FillType.Solid: // flat color 366 | return {background: this.color.toString(), mixBlendMode: this.blendMode}; 367 | case FillType.Gradient:// gradient 368 | return {background: this.gradient.toString(model), mixBlendMode: this.blendMode}; 369 | default: 370 | return {}; 371 | } 372 | } 373 | } 374 | 375 | export class Shadow { 376 | static _class = 'shadow'; 377 | 378 | toString() { 379 | return `${this.offsetX}px ${this.offsetY}px ${this.blurRadius}px ${this.spread}px ${this.color}` 380 | } 381 | } 382 | export class InnerShadow extends Shadow { 383 | static _class = 'innerShadow'; 384 | 385 | toString() { 386 | return `inset ${super.toString()}`; 387 | } 388 | } 389 | export class Border { 390 | static _class = 'border'; 391 | 392 | toString() { 393 | let {color, thickness, fillType} = this; 394 | return `${thickness}px solid ${color}`; 395 | } 396 | 397 | toStyle(isSvg = false) { 398 | if (!isSvg) { 399 | return {border: this.toString()}; 400 | } else { 401 | let {color, thickness, fillType} = this; 402 | return {stroke: color, strokeWidth: thickness} 403 | } 404 | } 405 | } 406 | 407 | 408 | export class SymbolInstance { 409 | static _class = 'symbolInstance'; 410 | } 411 | export class Document { 412 | static _class = 'document'; 413 | } 414 | 415 | export class MSJSONFileReference { 416 | static _class = 'MSJSONFileReference'; 417 | 418 | constructor(zip, parse) { 419 | this.zip = zip; 420 | this.parse = parse; 421 | } 422 | 423 | async getInstance() { 424 | 425 | switch (this['_ref_class']) { 426 | case "MSImmutablePage": 427 | let json = await this.zip.file(this['_ref'] + '.json') 428 | .async('string'); 429 | return this.parse(JSON.parse(json), this.zip); 430 | case "MSImageData": 431 | let buffer = await this.zip.file(this['_ref'] + '.png') 432 | .async('nodebuffer'); 433 | let blob = new Blob([buffer], {type: 'image/png'}); 434 | return URL.createObjectURL(blob); 435 | } 436 | } 437 | } 438 | const Alignment = { 439 | Left: 4, 440 | Center: 2, 441 | Right: 1, 442 | Justify: 3, 443 | }; 444 | const AlignmentString = { 445 | 4: 'left', 446 | 2: 'center', 447 | 1: 'right', 448 | 3: 'justify', 449 | }; 450 | export class TextStyle { 451 | static _class = 'textStyle'; 452 | 453 | toStyle(model) { 454 | let style = {}; 455 | // window.model = model; 456 | // if(model.attributedString.archivedAttributedString.NSAttributes instanceof Array){ 457 | // debugger; 458 | // } 459 | 460 | if (this.encodedAttributes) { 461 | if (typeof this.encodedAttributes.NSParagraphStyle !== 'undefined' && this.encodedAttributes.NSParagraphStyle.NSAlignment !== Alignment.Left) { 462 | Object.assign(style, {textAlign: AlignmentString[this.encodedAttributes.NSParagraphStyle.NSAlignment]}); 463 | } 464 | if (typeof this.encodedAttributes.paragraphStyle !== 'undefined' && this.encodedAttributes.paragraphStyle.NSAlignment !== Alignment.Left) { 465 | Object.assign(style, {textAlign: AlignmentString[this.encodedAttributes.paragraphStyle.NSAlignment]}); 466 | } 467 | 468 | //Backwards compatible 469 | if (typeof this.encodedAttributes.NSColor === 'undefined' && typeof this.encodedAttributes.MSAttributedStringColorDictionaryAttribute !== 'undefined' ) { 470 | this.encodedAttributes.NSColor = this.encodedAttributes.MSAttributedStringColorDictionaryAttribute; 471 | } 472 | } 473 | 474 | if (model.textBehaviour) { 475 | Object.assign(style, {width: model.frame.width}); 476 | } else { 477 | Object.assign(style, {whiteSpace: 'nowrap'}); 478 | } 479 | return { 480 | fontSize: this.encodedAttributes.MSAttributedStringFontAttribute.NSFontDescriptorAttributes.NSFontSizeAttribute, 481 | color: this.encodedAttributes.NSColor && Color.prototype.toString.call(this.encodedAttributes.NSColor), 482 | ...style, 483 | }; 484 | } 485 | 486 | } 487 | export class Bitmap { 488 | static _class = 'bitmap'; 489 | } 490 | function toS(a){ 491 | return a.toPrecision(6).replace(/\.?0+$/,''); 492 | } 493 | export class ShapePath { 494 | static _class = 'shapePath'; 495 | 496 | getXY(s) { 497 | let {x, y} = s2p(s); 498 | x = this.frame._x + this.frame._width * x; 499 | y = this.frame._y + this.frame._height * y; 500 | return {x, y} 501 | } 502 | 503 | toD() { 504 | let path = this.path; 505 | let {x, y} = this.getXY(path.points[0].point); 506 | let ret = `M${toS(x)},${toS(y)}`; 507 | let n = path['isClosed'] ? path.points.length + 1 : path.points.length; 508 | for (let i = 1; i < n; ++i) { 509 | let now = i; 510 | if (now === path.points.length) { 511 | now = 0; 512 | } 513 | let prev = (i - 1); 514 | let {x: x1, y: y1} = this.getXY(path.points[prev].curveFrom); 515 | let {x: x2, y: y2} = this.getXY(path.points[now].curveTo); 516 | let {x, y} = this.getXY(path.points[now].point); 517 | if (!path.points[now].hasCurveTo && !path.points[now].hasCurveFrom){ 518 | ret += `L${toS(x)},${toS(y)}`; 519 | }else { 520 | ret += `C${toS(x1)},${toS(y1)} ${toS(x2)},${toS(y2)} ${toS(x)},${toS(y)}`; 521 | } 522 | } 523 | 524 | if (path['isClosed']) { 525 | ret += 'Z'; 526 | } 527 | return ret; 528 | } 529 | } 530 | export class Rectangle extends ShapePath { 531 | static _class = 'rectangle'; 532 | } 533 | export class Oval extends ShapePath { 534 | static _class = 'oval'; 535 | } 536 | export class Star extends ShapePath { 537 | static _class = 'star'; 538 | } 539 | export class Polygon extends ShapePath { 540 | static _class = 'polygon'; 541 | } 542 | export class Triangle extends ShapePath { 543 | static _class = 'triangle'; 544 | } 545 | export class Path { 546 | static _class = 'path'; 547 | 548 | } 549 | function s2p(s) { 550 | let [x, y] = s.substr(1, s.length - 2).split(',').map(Number); 551 | return {x, y} 552 | } 553 | export class CurvePoint { 554 | static _class = 'curvePoint'; 555 | 556 | } 557 | export class Text { 558 | static _class = 'text'; 559 | 560 | } 561 | export class MSAttributedString { 562 | static _class = "MSAttributedString"; 563 | // 564 | // set archivedAttributedString({_archive}) { 565 | // Object.assign(this, parseBase64(_archive)); 566 | // } 567 | } -------------------------------------------------------------------------------- /src/utils/JSX.js: -------------------------------------------------------------------------------- 1 | import camelCase from 'lodash/camelCase' 2 | 3 | export function cssText2obj(cssText) { 4 | let ret = {}; 5 | cssText.split(';') 6 | .map(s => s.trim()) 7 | .filter(s => s.length > 0) 8 | .forEach(s => { 9 | let [prop, value] = s.split(':').map(s => s.trim()); 10 | ret[camelCase(prop)] = value; 11 | }); 12 | return ret; 13 | } 14 | 15 | export function cssText2jsxCode(cssText) { 16 | return `{${Object.entries(cssText2obj(cssText)).map(([prop, value]) => `${prop}: '${value}'`).join(', ')}}`; 17 | } 18 | 19 | export function getFormattedJSXCode(o, depth = 0) { 20 | let spaces = ''; 21 | for (let i = 0; i < depth; ++i) { 22 | spaces += ' '; 23 | } 24 | let tagName = o.tagName.toLowerCase(); 25 | switch (tagName) { 26 | case 'img': 27 | return `${spaces}\n`; 28 | case 'path': 29 | return `${spaces}\n`; 30 | case 'span': 31 | return `${spaces}${o.innerText}\n`; 32 | default: 33 | if (o.childNodes.length === 0) { 34 | return `${spaces}<${tagName} style={${cssText2jsxCode(o.style.cssText)}}/>\n`; 35 | } 36 | return `${spaces}<${tagName} style={${cssText2jsxCode(o.style.cssText)}}>\n${Array.prototype.map.call(o.childNodes, child => getFormattedJSXCode(child, depth + 1)).join('')}${spaces}\n` 37 | } 38 | } 39 | 40 | export function getReactCode(o) { 41 | return `import React from 'react' 42 | 43 | export default class MyComp extends React.Component { 44 | render(){ 45 | return ( 46 | ${getFormattedJSXCode(o, 3, true)} ); 47 | } 48 | }` 49 | } -------------------------------------------------------------------------------- /src/utils/bplist-parser.js: -------------------------------------------------------------------------------- 1 | // adapted from http://code.google.com/p/plist/source/browse/trunk/src/com/dd/plist/BinaryPropertyListParser.java 2 | 3 | var bigInt = require("big-integer"); 4 | var debug = false; 5 | 6 | exports.maxObjectSize = 100 * 1000 * 1000; // 100Meg 7 | exports.maxObjectCount = 32768; 8 | 9 | // EPOCH = new SimpleDateFormat("yyyy MM dd zzz").parse("2001 01 01 GMT").getTime(); 10 | // ...but that's annoying in a static initializer because it can throw exceptions, ick. 11 | // So we just hardcode the correct value. 12 | var EPOCH = 978307200000; 13 | 14 | // UID object definition 15 | function UID(id) { 16 | this.UID = id; 17 | } 18 | 19 | function _parse(o, $objects) { 20 | if (o instanceof UID) { 21 | return _parse($objects[o['UID']], $objects); 22 | } else if (o instanceof Buffer) { 23 | return o.toString(); 24 | } else if (typeof o === 'object') { 25 | for (let prop in o) { 26 | o[prop] = _parse(o[prop], $objects); 27 | } 28 | if ('$class' in o) { 29 | if (o['$class']['$classname'] === 'NSMutableArray' || o['$class']['$classname'] === 'NSArray') { 30 | return o['NS.objects']; 31 | } 32 | if (o['$class']['$classname'] === 'NSDictionary' || o['$class']['$classname'] === 'NSMutableDictionary') { 33 | let ret = {}; 34 | for (let i = 0; i < o['NS.keys'].length; ++i) { 35 | ret[o['NS.keys'][i]] = o['NS.objects'][i]; 36 | } 37 | return ret; 38 | } 39 | if (o['$class']['$classname'] === 'NSColor') { 40 | if (!('NSRGB' in o)) { 41 | return {red: 0, green: 0, blue: 0, alpha: 1}; 42 | } 43 | let [red, green, blue, alpha] = o['NSRGB'].substr(0, o['NSRGB'].length - 1).split(' ').map(Number); 44 | return {red, green, blue, alpha}; 45 | } 46 | delete o['$class']; 47 | } 48 | return o; 49 | } else if (o instanceof Array) { 50 | return o.map(oo => _parse(oo, $objects)) 51 | } else { 52 | return o; 53 | } 54 | } 55 | export function parse(json) { 56 | let {$objects} = json; 57 | return _parse($objects[1], $objects); 58 | } 59 | export function parseBase64(base64Str) { 60 | return parseBuffer(Buffer.from(base64Str, 'base64')); 61 | } 62 | export function parseBuffer(buffer) { 63 | var result = {}; 64 | 65 | // check header 66 | var header = buffer.slice(0, 'bplist'.length).toString('utf8'); 67 | if (header !== 'bplist') { 68 | throw new Error("Invalid binary plist. Expected 'bplist' at offset 0."); 69 | } 70 | 71 | // Handle trailer, last 32 bytes of the file 72 | var trailer = buffer.slice(buffer.length - 32, buffer.length); 73 | // 6 null bytes (index 0 to 5) 74 | var offsetSize = trailer.readUInt8(6); 75 | if (debug) { 76 | console.log("offsetSize: " + offsetSize); 77 | } 78 | var objectRefSize = trailer.readUInt8(7); 79 | if (debug) { 80 | console.log("objectRefSize: " + objectRefSize); 81 | } 82 | var numObjects = readUInt64BE(trailer, 8); 83 | if (debug) { 84 | console.log("numObjects: " + numObjects); 85 | } 86 | var topObject = readUInt64BE(trailer, 16); 87 | if (debug) { 88 | console.log("topObject: " + topObject); 89 | } 90 | var offsetTableOffset = readUInt64BE(trailer, 24); 91 | if (debug) { 92 | console.log("offsetTableOffset: " + offsetTableOffset); 93 | } 94 | 95 | if (numObjects > exports.maxObjectCount) { 96 | throw new Error("maxObjectCount exceeded"); 97 | } 98 | 99 | // Handle offset table 100 | var offsetTable = []; 101 | 102 | for (var i = 0; i < numObjects; i++) { 103 | var offsetBytes = buffer.slice(offsetTableOffset + i * offsetSize, offsetTableOffset + (i + 1) * offsetSize); 104 | offsetTable[i] = readUInt(offsetBytes, 0); 105 | if (debug) { 106 | console.log("Offset for Object #" + i + " is " + offsetTable[i] + " [" + offsetTable[i].toString(16) + "]"); 107 | } 108 | } 109 | 110 | // Parses an object inside the currently parsed binary property list. 111 | // For the format specification check 112 | // 113 | // Apple's binary property list parser implementation. 114 | function parseObject(tableOffset) { 115 | var offset = offsetTable[tableOffset]; 116 | var type = buffer[offset]; 117 | var objType = (type & 0xF0) >> 4; //First 4 bits 118 | var objInfo = (type & 0x0F); //Second 4 bits 119 | switch (objType) { 120 | case 0x0: 121 | return parseSimple(); 122 | case 0x1: 123 | return parseInteger(); 124 | case 0x8: 125 | return parseUID(); 126 | case 0x2: 127 | return parseReal(); 128 | case 0x3: 129 | return parseDate(); 130 | case 0x4: 131 | return parseData(); 132 | case 0x5: // ASCII 133 | return parsePlistString(); 134 | case 0x6: // UTF-16 135 | return parsePlistString(true); 136 | case 0xA: 137 | return parseArray(); 138 | case 0xD: 139 | return parseDictionary(); 140 | default: 141 | throw new Error("Unhandled type 0x" + objType.toString(16)); 142 | } 143 | 144 | function parseSimple() { 145 | //Simple 146 | switch (objInfo) { 147 | case 0x0: // null 148 | return null; 149 | case 0x8: // false 150 | return false; 151 | case 0x9: // true 152 | return true; 153 | case 0xF: // filler byte 154 | return null; 155 | default: 156 | throw new Error("Unhandled simple type 0x" + objType.toString(16)); 157 | } 158 | } 159 | 160 | function bufferToHexString(buffer) { 161 | var str = ''; 162 | var i; 163 | for (i = 0; i < buffer.length; i++) { 164 | if (buffer[i] != 0x00) { 165 | break; 166 | } 167 | } 168 | for (; i < buffer.length; i++) { 169 | var part = '00' + buffer[i].toString(16); 170 | str += part.substr(part.length - 2); 171 | } 172 | return str; 173 | } 174 | 175 | function parseInteger() { 176 | var length = Math.pow(2, objInfo); 177 | if (length > 4) { 178 | var data = buffer.slice(offset + 1, offset + 1 + length); 179 | var str = bufferToHexString(data); 180 | return bigInt(str, 16); 181 | } 182 | if (length < exports.maxObjectSize) { 183 | return readUInt(buffer.slice(offset + 1, offset + 1 + length)); 184 | } else { 185 | throw new Error("To little heap space available! Wanted to read " + length + " bytes, but only " + exports.maxObjectSize + " are available."); 186 | } 187 | } 188 | 189 | function parseUID() { 190 | var length = objInfo + 1; 191 | if (length < exports.maxObjectSize) { 192 | return new UID(readUInt(buffer.slice(offset + 1, offset + 1 + length))); 193 | } else { 194 | throw new Error("To little heap space available! Wanted to read " + length + " bytes, but only " + exports.maxObjectSize + " are available."); 195 | } 196 | } 197 | 198 | function parseReal() { 199 | var length = Math.pow(2, objInfo); 200 | if (length < exports.maxObjectSize) { 201 | var realBuffer = buffer.slice(offset + 1, offset + 1 + length); 202 | if (length === 4) { 203 | return realBuffer.readFloatBE(0); 204 | } 205 | else if (length === 8) { 206 | return realBuffer.readDoubleBE(0); 207 | } 208 | } else { 209 | throw new Error("To little heap space available! Wanted to read " + length + " bytes, but only " + exports.maxObjectSize + " are available."); 210 | } 211 | } 212 | 213 | function parseDate() { 214 | if (objInfo != 0x3) { 215 | console.error("Unknown date type :" + objInfo + ". Parsing anyway..."); 216 | } 217 | var dateBuffer = buffer.slice(offset + 1, offset + 9); 218 | return new Date(EPOCH + (1000 * dateBuffer.readDoubleBE(0))); 219 | } 220 | 221 | function parseData() { 222 | var dataoffset = 1; 223 | var length = objInfo; 224 | if (objInfo == 0xF) { 225 | var int_type = buffer[offset + 1]; 226 | var intType = (int_type & 0xF0) / 0x10; 227 | if (intType != 0x1) { 228 | console.error("0x4: UNEXPECTED LENGTH-INT TYPE! " + intType); 229 | } 230 | var intInfo = int_type & 0x0F; 231 | var intLength = Math.pow(2, intInfo); 232 | dataoffset = 2 + intLength; 233 | if (intLength < 3) { 234 | length = readUInt(buffer.slice(offset + 2, offset + 2 + intLength)); 235 | } else { 236 | length = readUInt(buffer.slice(offset + 2, offset + 2 + intLength)); 237 | } 238 | } 239 | if (length < exports.maxObjectSize) { 240 | return buffer.slice(offset + dataoffset, offset + dataoffset + length); 241 | } else { 242 | throw new Error("To little heap space available! Wanted to read " + length + " bytes, but only " + exports.maxObjectSize + " are available."); 243 | } 244 | } 245 | 246 | function parsePlistString(isUtf16) { 247 | isUtf16 = isUtf16 || 0; 248 | var enc = "utf8"; 249 | var length = objInfo; 250 | var stroffset = 1; 251 | if (objInfo == 0xF) { 252 | var int_type = buffer[offset + 1]; 253 | var intType = (int_type & 0xF0) / 0x10; 254 | if (intType != 0x1) { 255 | console.err("UNEXPECTED LENGTH-INT TYPE! " + intType); 256 | } 257 | var intInfo = int_type & 0x0F; 258 | var intLength = Math.pow(2, intInfo); 259 | var stroffset = 2 + intLength; 260 | if (intLength < 3) { 261 | length = readUInt(buffer.slice(offset + 2, offset + 2 + intLength)); 262 | } else { 263 | length = readUInt(buffer.slice(offset + 2, offset + 2 + intLength)); 264 | } 265 | } 266 | // length is String length -> to get byte length multiply by 2, as 1 character takes 2 bytes in UTF-16 267 | length *= (isUtf16 + 1); 268 | if (length < exports.maxObjectSize) { 269 | var plistString = new Buffer(buffer.slice(offset + stroffset, offset + stroffset + length)); 270 | if (isUtf16) { 271 | plistString = swapBytes(plistString); 272 | enc = "ucs2"; 273 | } 274 | return plistString.toString(enc); 275 | } else { 276 | throw new Error("To little heap space available! Wanted to read " + length + " bytes, but only " + exports.maxObjectSize + " are available."); 277 | } 278 | } 279 | 280 | function parseArray() { 281 | var length = objInfo; 282 | var arrayoffset = 1; 283 | if (objInfo == 0xF) { 284 | var int_type = buffer[offset + 1]; 285 | var intType = (int_type & 0xF0) / 0x10; 286 | if (intType != 0x1) { 287 | console.error("0xa: UNEXPECTED LENGTH-INT TYPE! " + intType); 288 | } 289 | var intInfo = int_type & 0x0F; 290 | var intLength = Math.pow(2, intInfo); 291 | arrayoffset = 2 + intLength; 292 | if (intLength < 3) { 293 | length = readUInt(buffer.slice(offset + 2, offset + 2 + intLength)); 294 | } else { 295 | length = readUInt(buffer.slice(offset + 2, offset + 2 + intLength)); 296 | } 297 | } 298 | if (length * objectRefSize > exports.maxObjectSize) { 299 | throw new Error("To little heap space available!"); 300 | } 301 | var array = []; 302 | for (var i = 0; i < length; i++) { 303 | var objRef = readUInt(buffer.slice(offset + arrayoffset + i * objectRefSize, offset + arrayoffset + (i + 1) * objectRefSize)); 304 | array[i] = parseObject(objRef); 305 | } 306 | return array; 307 | } 308 | 309 | function parseDictionary() { 310 | var length = objInfo; 311 | var dictoffset = 1; 312 | if (objInfo == 0xF) { 313 | var int_type = buffer[offset + 1]; 314 | var intType = (int_type & 0xF0) / 0x10; 315 | if (intType != 0x1) { 316 | console.error("0xD: UNEXPECTED LENGTH-INT TYPE! " + intType); 317 | } 318 | var intInfo = int_type & 0x0F; 319 | var intLength = Math.pow(2, intInfo); 320 | dictoffset = 2 + intLength; 321 | if (intLength < 3) { 322 | length = readUInt(buffer.slice(offset + 2, offset + 2 + intLength)); 323 | } else { 324 | length = readUInt(buffer.slice(offset + 2, offset + 2 + intLength)); 325 | } 326 | } 327 | if (length * 2 * objectRefSize > exports.maxObjectSize) { 328 | throw new Error("To little heap space available!"); 329 | } 330 | if (debug) { 331 | console.log("Parsing dictionary #" + tableOffset); 332 | } 333 | var dict = {}; 334 | for (var i = 0; i < length; i++) { 335 | var keyRef = readUInt(buffer.slice(offset + dictoffset + i * objectRefSize, offset + dictoffset + (i + 1) * objectRefSize)); 336 | var valRef = readUInt(buffer.slice(offset + dictoffset + (length * objectRefSize) + i * objectRefSize, offset + dictoffset + (length * objectRefSize) + (i + 1) * objectRefSize)); 337 | var key = parseObject(keyRef); 338 | var val = parseObject(valRef); 339 | if (debug) { 340 | console.log(" DICT #" + tableOffset + ": Mapped " + key + " to " + val); 341 | } 342 | dict[key] = val; 343 | } 344 | return dict; 345 | } 346 | } 347 | 348 | return /*[*/ parse(parseObject(topObject)) /*]*/; 349 | }; 350 | 351 | function readUInt(buffer, start) { 352 | start = start || 0; 353 | 354 | var l = 0; 355 | for (var i = start; i < buffer.length; i++) { 356 | l <<= 8; 357 | l |= buffer[i] & 0xFF; 358 | } 359 | return l; 360 | } 361 | 362 | // we're just going to toss the high order bits because javascript doesn't have 64-bit ints 363 | function readUInt64BE(buffer, start) { 364 | var data = buffer.slice(start, start + 8); 365 | return data.readUInt32BE(4, 8); 366 | } 367 | 368 | function swapBytes(buffer) { 369 | var len = buffer.length; 370 | for (var i = 0; i < len; i += 2) { 371 | var a = buffer[i]; 372 | buffer[i] = buffer[i + 1]; 373 | buffer[i + 1] = a; 374 | } 375 | return buffer; 376 | } 377 | -------------------------------------------------------------------------------- /src/utils/index.js: -------------------------------------------------------------------------------- 1 | import * as models from '../models/index' 2 | export * from './JSX' 3 | class Dummy { 4 | constructor(json) { 5 | setProps(this, json); 6 | } 7 | 8 | toString() { 9 | return JSON.stringify(this.json); 10 | } 11 | } 12 | let mapping = {}; 13 | for (let modelName in models) { 14 | let model = models[modelName]; 15 | mapping[model._class] = model; 16 | } 17 | 18 | function isClass(o) { 19 | return o && typeof o === 'object' && '_class' in o && typeof o['_class'] === 'string' 20 | } 21 | function isPlistArchive(o) { 22 | return o && typeof o === 'object' && '_archive' in o; 23 | } 24 | import {parseBase64} from './bplist-parser' 25 | export let objectMapping = {}; 26 | export let symbolMapping = {}; 27 | 28 | export function clear() { 29 | objectMapping = {}; 30 | symbolMapping = {}; 31 | } 32 | 33 | export function getObjectById(id) { 34 | return objectMapping[id]; 35 | } 36 | export function getSymbolById(id) { 37 | return symbolMapping[id]; 38 | } 39 | 40 | export function parse(json, zip) { 41 | 42 | if (isClass(json)) { 43 | let className = json['_class']; 44 | if (className in mapping) { 45 | let ret = new mapping[className](zip, parse); 46 | setProps(ret, json, zip); 47 | if (className === 'symbolMaster') { 48 | symbolMapping[json['symbolID']] = ret; 49 | } 50 | objectMapping[json['do_objectID']] = ret; 51 | return ret; 52 | } else { 53 | return json; 54 | } 55 | } else if (isPlistArchive(json)) { 56 | return parseBase64(json['_archive']); 57 | } else if (typeof json === 'object') { 58 | for (let prop in json) { 59 | json[prop] = parse(json[prop], zip); 60 | } 61 | 62 | return json; 63 | } 64 | else if (json instanceof Array) { 65 | return json.map(o => parse(o, zip)); 66 | } else { 67 | return json; 68 | } 69 | } 70 | export function setProps(obj, json, zip) { 71 | for (let prop in json) { 72 | obj[prop] = parse(json[prop], zip); 73 | } 74 | } 75 | 76 | -------------------------------------------------------------------------------- /src/utils/pretty-bytes.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const UNITS = ['B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; 3 | 4 | module.exports = num => { 5 | if (!Number.isFinite(num)) { 6 | throw new TypeError(`Expected a finite number, got ${typeof num}: ${num}`); 7 | } 8 | 9 | const neg = num < 0; 10 | 11 | if (neg) { 12 | num = -num; 13 | } 14 | 15 | if (num < 1) { 16 | return (neg ? '-' : '') + num + ' B'; 17 | } 18 | 19 | const exponent = Math.min(Math.floor(Math.log(num) / Math.log(1000)), UNITS.length - 1); 20 | const numStr = Number((num / Math.pow(1000, exponent)).toPrecision(3)); 21 | const unit = UNITS[exponent]; 22 | 23 | return (neg ? '-' : '') + numStr + ' ' + unit; 24 | }; -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 2 | module.exports = { 3 | module: { 4 | rules: [ 5 | { 6 | test: /\.jsx?$/, 7 | exclude: [ 8 | /node_modules/ 9 | ], 10 | loaders: ["babel-loader"] 11 | }, 12 | { 13 | test: /\.styl$/, 14 | loaders: ['style-loader', 'css-loader', "stylus-loader"] 15 | }, 16 | { 17 | test: /\.css$/, 18 | loaders: ['style-loader', 'css-loader'] 19 | }, 20 | { 21 | test: /\.(sketch|png)$/, 22 | loaders: ["file-loader"] 23 | }, 24 | ] 25 | 26 | }, 27 | resolve: { 28 | extensions: [".js", 'jsx', ".json"], 29 | }, 30 | plugins: [new HtmlWebpackPlugin()], 31 | }; --------------------------------------------------------------------------------