├── .gitignore ├── assets ├── grid.png └── sketch-mac-icon@2x.png ├── src ├── renderer.ts ├── autocomplete │ ├── index.ts │ └── html.ts ├── error-handler.ts ├── blank-slate.tsx ├── editors │ ├── style.ts │ ├── markup.ts │ ├── json.ts │ └── index.ts ├── eval.ts ├── css2obj.ts ├── git.ts ├── main.ts ├── window.ts ├── editor-actions.ts ├── common.ts ├── git-log.tsx ├── open.tsx ├── actions │ └── increment.ts ├── components │ ├── InpuPopover.tsx │ ├── BroadcastPopover.tsx │ ├── MediaPopover.tsx │ └── DiffImagePopover.tsx ├── navbar.tsx ├── quick-search.tsx ├── generators │ ├── vuejs.ts │ └── react.ts ├── menu-template.ts ├── sidebar.tsx ├── style-palette.ts ├── css-striper.ts ├── types.ts ├── inspector.ts ├── server.ts ├── app.tsx ├── assets.tsx ├── style-palette-view.tsx ├── preview-render.tsx ├── workspace.ts ├── editors.tsx ├── settings.tsx ├── component.ts ├── typer.ts ├── utils.ts ├── json.ts └── sketch.ts ├── screenshots ├── main.png ├── code-coverage.png ├── remote-testing.png ├── style-palette.png └── import-from-sketch.png ├── .prettierrc.json ├── .npmrc ├── tsconfig.json ├── tslint.json ├── unicycle.sketchplugin └── Contents │ └── Sketch │ ├── manifest.json │ └── export.cocoascript ├── .eslintrc.js ├── rebuild.sh ├── broadcast.html ├── package.json ├── index.html ├── style.css └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | -------------------------------------------------------------------------------- /assets/grid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gimenete/unicycle/HEAD/assets/grid.png -------------------------------------------------------------------------------- /src/renderer.ts: -------------------------------------------------------------------------------- 1 | require('source-map-support').install() 2 | 3 | require('./app') 4 | -------------------------------------------------------------------------------- /screenshots/main.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gimenete/unicycle/HEAD/screenshots/main.png -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": false, 4 | "printWidth": 100 5 | } 6 | -------------------------------------------------------------------------------- /assets/sketch-mac-icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gimenete/unicycle/HEAD/assets/sketch-mac-icon@2x.png -------------------------------------------------------------------------------- /screenshots/code-coverage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gimenete/unicycle/HEAD/screenshots/code-coverage.png -------------------------------------------------------------------------------- /screenshots/remote-testing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gimenete/unicycle/HEAD/screenshots/remote-testing.png -------------------------------------------------------------------------------- /screenshots/style-palette.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gimenete/unicycle/HEAD/screenshots/style-palette.png -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | runtime = electron 2 | target = 1.7.9 3 | target_arch = x64 4 | disturl = https://atom.io/download/electron 5 | -------------------------------------------------------------------------------- /screenshots/import-from-sketch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gimenete/unicycle/HEAD/screenshots/import-from-sketch.png -------------------------------------------------------------------------------- /src/autocomplete/index.ts: -------------------------------------------------------------------------------- 1 | import html from './html' 2 | 3 | const register = () => { 4 | html() 5 | } 6 | 7 | export default register 8 | -------------------------------------------------------------------------------- /src/error-handler.ts: -------------------------------------------------------------------------------- 1 | import { notification } from 'antd' 2 | 3 | const errorHandler = (err: Error) => { 4 | console.error(err) 5 | notification.error({ 6 | message: 'Error', 7 | description: err.message 8 | }) 9 | } 10 | 11 | export default errorHandler 12 | -------------------------------------------------------------------------------- /src/blank-slate.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | const BlankSlate = () => { 4 | return ( 5 |
6 |

Unicycle

7 |

The app that designers and frontend developers will ❤️

8 |
9 | ) 10 | } 11 | 12 | export default BlankSlate 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "module": "commonjs", 5 | "outDir": "./lib", 6 | "strict": true, 7 | "jsx": "react", 8 | "noUnusedLocals": true, 9 | "inlineSourceMap": true 10 | }, 11 | "compileOnSave": true, 12 | "files": [ 13 | "./node_modules/monaco-editor/monaco.d.ts", 14 | "./node_modules/@types/mousetrap/index.d.ts" 15 | ], 16 | "include": ["src/**/*.ts", "src/**/*.tsx"] 17 | } 18 | -------------------------------------------------------------------------------- /src/editors/style.ts: -------------------------------------------------------------------------------- 1 | import Editor from './' 2 | 3 | import { ErrorHandler } from '../types' 4 | 5 | class StyleEditor extends Editor { 6 | constructor(element: HTMLElement, errorHandler: ErrorHandler) { 7 | super( 8 | 'styles.scss', 9 | element, 10 | { 11 | language: 'scss' 12 | }, 13 | errorHandler 14 | ) 15 | } 16 | 17 | public update() { 18 | // nothing to do yet 19 | } 20 | } 21 | 22 | export default StyleEditor 23 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint:recommended", 3 | "rules": { 4 | "quotemark": [true, "single", "jsx-double"], 5 | "semicolon": [false, "always"], 6 | "trailing-comma": false, 7 | "object-literal-sort-keys": false, 8 | "interface-name": false, 9 | "curly": false, 10 | "arrow-parens": false, 11 | "no-var-requires": false, 12 | "max-classes-per-file": false, 13 | "no-console": false, 14 | "ordered-imports": false 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/eval.ts: -------------------------------------------------------------------------------- 1 | export const evaluate = (code: string, options: { [index: string]: any }) => { 2 | const keys: string[] = [] 3 | const values: any[] = [] 4 | Object.keys(options).forEach(key => { 5 | keys.push(key) 6 | values.push(options[key]) 7 | }) 8 | keys.push(code) 9 | const f = Function.apply({}, keys) 10 | return f.apply({}, values) 11 | } 12 | 13 | export const evaluateExpression = (code: string, options: {}) => { 14 | return evaluate(`return ${code}`, options) 15 | } 16 | -------------------------------------------------------------------------------- /src/editors/markup.ts: -------------------------------------------------------------------------------- 1 | import Editor from './' 2 | 3 | import { ErrorHandler } from '../types' 4 | 5 | class MarkupEditor extends Editor { 6 | constructor(element: HTMLElement, errorHandler: ErrorHandler) { 7 | super( 8 | 'index.html', 9 | element, 10 | { 11 | language: 'html' 12 | }, 13 | errorHandler 14 | ) 15 | this.errorHandler = errorHandler 16 | } 17 | 18 | public update() { 19 | // nothing to do yet 20 | } 21 | } 22 | 23 | export default MarkupEditor 24 | -------------------------------------------------------------------------------- /unicycle.sketchplugin/Contents/Sketch/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Unicycle exporting tool", 3 | "description": "Exports the current selection to Unicycle", 4 | "author": "Level Apps S.L.", 5 | "homepage": "http://unicycle.io", 6 | "version": "1.0", 7 | "identifier": "io.unicycle.sketch", 8 | "compatibleVersion": "3", 9 | "bundleVersion": 1, 10 | "commands": [ 11 | { 12 | "name": "Export current selection", 13 | "identifier": "export", 14 | "script": "export.cocoascript" 15 | } 16 | ], 17 | "menu": { 18 | "items": ["export"] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['eslint:recommended', 'prettier'], 3 | plugins: ['prettier'], 4 | env: { 5 | es6: true, 6 | node: true 7 | }, 8 | globals: { 9 | document: false, 10 | navigator: false, 11 | window: false 12 | }, 13 | parserOptions: { 14 | ecmaVersion: 2017 15 | }, 16 | rules: { 17 | 'prettier/prettier': [ 18 | 'error', 19 | { 20 | singleQuote: true, 21 | semi: false 22 | } 23 | ], 24 | 'no-unused-vars': ['error', { vars: 'all', args: 'none' }], 25 | 'no-console': ['off'] 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/css2obj.ts: -------------------------------------------------------------------------------- 1 | const postcss = require('postcss') 2 | const camelcase = require('camelcase') 3 | 4 | const css2obj = (css: string): { [index: string]: string | number } => { 5 | const result = postcss.parse(css) 6 | return result.nodes.reduce((obj: any, node: any) => { 7 | if (node.type === 'decl') { 8 | const prop = camelcase(node.prop) 9 | let value = node.value 10 | // tslint:disable-next-line:triple-equals 11 | if (value == +value || value === `${parseFloat(value)}px`) { 12 | value = parseFloat(value) 13 | } 14 | obj[prop] = value 15 | } 16 | return obj 17 | }, {}) 18 | } 19 | 20 | export default css2obj 21 | -------------------------------------------------------------------------------- /rebuild.sh: -------------------------------------------------------------------------------- 1 | TARGET=$(node -e "console.log(require('./package.json').devDependencies.electron.match(/\d+\.\d+.\d+/)[0])") 2 | 3 | # Electron's version. 4 | export npm_config_target=$TARGET 5 | # The architecture of Electron, can be ia32 or x64. 6 | export npm_config_arch=x64 7 | export npm_config_target_arch=x64 8 | # Download headers for Electron. 9 | export npm_config_disturl=https://atom.io/download/electron 10 | # Tell node-pre-gyp that we are building for Electron. 11 | export npm_config_runtime=electron 12 | # Tell node-pre-gyp to build module from source code. 13 | export npm_config_build_from_source=true 14 | # Install all dependencies, and store cache to ~/.electron-gyp. 15 | HOME=~/.electron-gyp npm install 16 | -------------------------------------------------------------------------------- /src/git.ts: -------------------------------------------------------------------------------- 1 | import * as Git from 'nodegit' 2 | import * as path from 'path' 3 | 4 | const { Repository, Reference } = Git 5 | 6 | const run = async () => { 7 | const repo = await Repository.open(path.join(__dirname, '..')) 8 | const branch = await repo.getCurrentBranch() 9 | const head = await repo.getHeadCommit() 10 | console.log('current', branch.shorthand(), head.sha()) 11 | const commit = await repo.getMasterCommit() 12 | const refNames = await repo.getReferenceNames(Reference.TYPE.LISTALL) 13 | for (const refname of refNames) { 14 | const ref = await Reference.lookup(repo, refname) 15 | console.log('ref', ref.name(), ref.isBranch(), ref.isHead(), ref.isRemote(), ref.isSymbolic()) 16 | } 17 | 18 | console.log('', commit.sha()) 19 | } 20 | 21 | run().catch(err => { 22 | console.error(err) 23 | process.exit(1) 24 | }) 25 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import * as electron from 'electron' 2 | import template from './menu-template' 3 | import { createWindow, createWindowIfNoWindows } from './window' 4 | 5 | const { app, Menu } = electron 6 | 7 | // This method will be called when Electron has finished 8 | // initialization and is ready to create browser windows. 9 | // Some APIs can only be used after this event occurs. 10 | app.on('ready', () => { 11 | createWindow('') 12 | const menu = Menu.buildFromTemplate(template) 13 | Menu.setApplicationMenu(menu) 14 | }) 15 | 16 | // Quit when all windows are closed. 17 | app.on('window-all-closed', () => { 18 | // On OS X it is common for applications and their menu bar 19 | // to stay active until the user quits explicitly with Cmd + Q 20 | if (process.platform !== 'darwin') { 21 | app.quit() 22 | } 23 | }) 24 | 25 | app.on('activate', () => { 26 | // On OS X it's common to re-create a window in the app when the 27 | // dock icon is clicked and there are no other windows open. 28 | createWindowIfNoWindows() 29 | }) 30 | -------------------------------------------------------------------------------- /broadcast.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Unicycle 6 | 7 | 8 | 9 |
10 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/window.ts: -------------------------------------------------------------------------------- 1 | import * as electron from 'electron' 2 | import * as path from 'path' 3 | import * as url from 'url' 4 | 5 | const { BrowserWindow } = electron 6 | 7 | import { isPackaged } from './utils' 8 | 9 | const windows: Electron.BrowserWindow[] = [] 10 | 11 | export const createWindow = (search: string) => { 12 | const packaged = isPackaged() 13 | const window = new BrowserWindow({ 14 | width: packaged ? 960 : 960 + 400, 15 | height: 800, 16 | titleBarStyle: 'hidden' 17 | }) 18 | 19 | window.loadURL( 20 | url.format({ 21 | pathname: path.join(__dirname, '..', 'index.html'), 22 | protocol: 'file:', 23 | slashes: true, 24 | search: windows.length === 0 ? 'first' : search 25 | }) 26 | ) 27 | 28 | window.on('closed', () => { 29 | const index = windows.indexOf(window) 30 | if (index >= 0) { 31 | windows.splice(index, 1) 32 | } 33 | }) 34 | 35 | windows.push(window) 36 | } 37 | 38 | export const createWindowIfNoWindows = () => { 39 | if (windows.length === 0) { 40 | createWindow('new') 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/editor-actions.ts: -------------------------------------------------------------------------------- 1 | import { decrement, increment } from './actions/increment' 2 | import Editors from './editors' 3 | 4 | const actions = [ 5 | { 6 | id: 'switch-markdup-editor', 7 | label: 'Switch to markup editor', 8 | // tslint:disable-next-line:no-bitwise 9 | keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KEY_1], 10 | contextMenuGroupId: 'navigation', 11 | contextMenuOrder: 1.5, 12 | run: () => Editors.selectEditor('markup') 13 | }, 14 | { 15 | id: 'switch-style-editor', 16 | label: 'Switch to style editor', 17 | // tslint:disable-next-line:no-bitwise 18 | keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KEY_2], 19 | contextMenuGroupId: 'navigation', 20 | contextMenuOrder: 1.5, 21 | run: () => Editors.selectEditor('style') 22 | }, 23 | { 24 | id: 'switch-states-editor', 25 | label: 'Switch to tests editor', 26 | // tslint:disable-next-line:no-bitwise 27 | keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KEY_3], 28 | contextMenuGroupId: 'navigation', 29 | contextMenuOrder: 1.5, 30 | run: () => Editors.selectEditor('data') 31 | }, 32 | increment, 33 | decrement 34 | ] 35 | 36 | export default actions 37 | -------------------------------------------------------------------------------- /src/common.ts: -------------------------------------------------------------------------------- 1 | // see https://gist.github.com/dcneiner/1137601 2 | // see https://www.w3.org/TR/CSS22/propidx.html 3 | export const inheritedProperties = [ 4 | 'azimuth', 5 | 'border-collapse', 6 | 'border-spacing', 7 | 'caption-side', 8 | 'color', 9 | 'cursor', 10 | 'direction', 11 | 'elevation', 12 | 'empty-cells', 13 | 'font-family', 14 | 'font-size', 15 | 'font-style', 16 | 'font-variant', 17 | 'font-weight', 18 | 'font', 19 | 'letter-spacing', 20 | 'line-height', 21 | 'list-style-image', 22 | 'list-style-position', 23 | 'list-style-type', 24 | 'list-style', 25 | 'orphans', 26 | 'pitch-range', 27 | 'pitch', 28 | 'quotes', 29 | 'richness', 30 | 'speak-header', 31 | 'speak-numeral', 32 | 'speak-punctuation', 33 | 'speak', 34 | 'speech-rate', 35 | 'stress', 36 | 'text-align', 37 | 'text-indent', 38 | 'text-transform', 39 | 'visibility', 40 | 'voice-family', 41 | 'volume', 42 | 'white-space', 43 | 'widows', 44 | 'word-spacing' 45 | ] 46 | 47 | export const textInheritedProperties = [ 48 | 'color', 49 | 'font-family', 50 | 'font-size', 51 | 'font-style', 52 | 'font-variant', 53 | 'font-weight', 54 | 'font', 55 | 'letter-spacing', 56 | 'line-height', 57 | 'speak-header', 58 | 'speak-numeral', 59 | 'speak-punctuation', 60 | 'speak', 61 | 'speech-rate', 62 | 'stress', 63 | 'text-align', 64 | 'text-indent', 65 | 'text-transform', 66 | 'voice-family', 67 | 'volume', 68 | 'white-space', 69 | 'word-spacing' 70 | ] 71 | -------------------------------------------------------------------------------- /src/git-log.tsx: -------------------------------------------------------------------------------- 1 | import { Timeline } from 'antd' 2 | import * as React from 'react' 3 | import * as Git from 'nodegit' 4 | 5 | import workspace from './workspace' 6 | import errorHandler from './error-handler' 7 | 8 | interface GitLogState { 9 | history: Git.Commit[] 10 | } 11 | 12 | class GitLog extends React.Component { 13 | public state: GitLogState = { 14 | history: [] 15 | } 16 | 17 | public componentDidMount() { 18 | this.fetchHistory().catch(errorHandler) 19 | } 20 | 21 | public render() { 22 | return ( 23 |
24 |

Git log

25 | 26 | {this.state.history.map(commit => ( 27 | 28 | {commit.message()} {commit.date().toISOString()} 29 | 30 | ))} 31 | 32 |
33 | ) 34 | } 35 | 36 | private async fetchHistory() { 37 | this.setState({ history: [] }) 38 | const gitHistory: Git.Commit[] = [] 39 | 40 | const repo = await workspace.getRepository() 41 | if (!repo) { 42 | errorHandler(new Error('Repository not found')) 43 | return 44 | } 45 | 46 | const head = await repo.getHeadCommit() 47 | if (!head) { 48 | errorHandler(new Error('No commits found')) 49 | return 50 | } 51 | const history = head.history() 52 | history.on('commit', (commit: Git.Commit) => { 53 | gitHistory.push(commit) 54 | }) 55 | history.on('end', () => { 56 | this.setState({ history: gitHistory }) 57 | }) 58 | const his = history as any 59 | his.start() 60 | } 61 | } 62 | 63 | export default GitLog 64 | -------------------------------------------------------------------------------- /src/autocomplete/html.ts: -------------------------------------------------------------------------------- 1 | const register = () => { 2 | monaco.languages.registerCompletionItemProvider('html', { 3 | provideCompletionItems: (model, position) => { 4 | const previousAndCurrentLine = model.getValueInRange({ 5 | startLineNumber: 1, 6 | startColumn: 1, 7 | endLineNumber: position.lineNumber + 1, 8 | endColumn: 1 9 | }) 10 | const currentLine = model.getLineContent(position.lineNumber) 11 | const tokens = monaco.editor.tokenize(previousAndCurrentLine, 'html') 12 | const currentLineTokens = tokens[tokens.length - 2] 13 | const currentToken = currentLineTokens.reduce((lastToken, token) => { 14 | return token.offset < position.column ? token : lastToken 15 | }) 16 | const index = currentLineTokens.indexOf(currentToken) 17 | const nextToken = currentLineTokens[index + 1] 18 | const tokenValue = currentLine.substring( 19 | currentToken.offset, 20 | nextToken ? nextToken.offset : currentLine.length 21 | ) 22 | if (tokenValue.endsWith(' @')) { 23 | return [ 24 | { 25 | label: '@if', 26 | kind: monaco.languages.CompletionItemKind.Snippet, 27 | detail: 'Conditional rendering', 28 | insertText: { 29 | value: 'if="$1"$2' 30 | } 31 | }, 32 | { 33 | label: '@loop', 34 | kind: monaco.languages.CompletionItemKind.Snippet, 35 | detail: 'Loop a collection', 36 | insertText: { 37 | value: 'loop="$1" @as="$2"$3' 38 | } 39 | } 40 | ] 41 | } 42 | return [] 43 | } 44 | }) 45 | } 46 | 47 | export default register 48 | -------------------------------------------------------------------------------- /src/open.tsx: -------------------------------------------------------------------------------- 1 | import * as crypto from 'crypto' 2 | import * as fs from 'fs-extra' 3 | import * as path from 'path' 4 | import * as React from 'react' 5 | import errorHandler from './error-handler' 6 | import { isPackaged } from './utils' 7 | import workspace from './workspace' 8 | 9 | import electron = require('electron') 10 | 11 | const { BrowserWindow, dialog, app } = electron.remote 12 | 13 | class OpenPage extends React.Component { 14 | constructor(props: any) { 15 | super(props) 16 | } 17 | 18 | public componentDidMount() { 19 | const { search } = document.location 20 | if (!isPackaged() && search === '?first') { 21 | this.loadProject(path.join(__dirname, '..', '..', 'example')) 22 | } else if (search === '?open') { 23 | this.openProject() 24 | } else if (search === '?new') { 25 | this.createProject() 26 | } 27 | } 28 | 29 | public render() { 30 | return
31 | } 32 | 33 | private openProject() { 34 | const paths = dialog.showOpenDialog(BrowserWindow.getFocusedWindow(), { 35 | properties: ['openDirectory'] 36 | }) 37 | if (!paths || paths.length === 0) { 38 | const window = BrowserWindow.getFocusedWindow() 39 | window.close() 40 | } 41 | this.loadProject(paths[0]) 42 | } 43 | 44 | private loadProject(fullpath: string) { 45 | workspace 46 | .loadProject(fullpath) 47 | .then(() => { 48 | const loader = document.querySelector('#loading') 49 | if (loader) loader.parentNode!.removeChild(loader) 50 | 51 | BrowserWindow.getFocusedWindow() 52 | }) 53 | .catch(errorHandler) 54 | } 55 | 56 | private createProject() { 57 | const random = crypto.randomBytes(4).toString('hex') 58 | const fullpath = path.join(app.getPath('userData'), 'project-' + random) 59 | Promise.resolve() 60 | .then(() => fs.mkdirp(fullpath)) 61 | .then(() => workspace.createProject(fullpath)) 62 | .catch(errorHandler) 63 | } 64 | } 65 | 66 | export default OpenPage 67 | -------------------------------------------------------------------------------- /src/actions/increment.ts: -------------------------------------------------------------------------------- 1 | const createRun = (delta: number) => { 2 | return (editor: monaco.editor.ICommonCodeEditor) => { 3 | const model = editor.getModel() 4 | const position = editor.getPosition() 5 | const column = position.column 6 | const line = model.getLineContent(position.lineNumber) 7 | const chunks = line.match(/\S+|\s/g) || [] 8 | let index = 1 9 | let chunkIndex = -1 10 | const current = chunks.find((chunk, i) => { 11 | if (index <= column && index + chunk.length > column) { 12 | chunkIndex = i 13 | return true 14 | } 15 | index += chunk.length 16 | return false 17 | }) 18 | if (current) { 19 | const numbers = current.match(/([-+]?\d+(.\d+)?)|\D+/g) || [] 20 | const firstNumber = numbers.findIndex(chunk => String(+chunk) === chunk) 21 | if (firstNumber >= 0) { 22 | numbers[firstNumber] = String(+numbers[firstNumber] + delta) 23 | chunks[chunkIndex] = numbers.join('') 24 | const newLine = chunks.join('') 25 | const range = new monaco.Range( 26 | position.lineNumber, 27 | 1, 28 | position.lineNumber, 29 | line.length + 1 30 | ) 31 | editor.executeEdits(newLine, [ 32 | { 33 | identifier: { 34 | major: 1, 35 | minor: 1 36 | }, 37 | range, 38 | text: newLine, 39 | forceMoveMarkers: false 40 | } 41 | ]) 42 | } 43 | } 44 | } 45 | } 46 | 47 | export const increment = { 48 | id: 'increment-value', 49 | label: 'Increment value', 50 | keybindings: [ 51 | // tslint:disable-next-line:no-bitwise 52 | monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KEY_K 53 | ], 54 | contextMenuGroupId: 'navigation', 55 | contextMenuOrder: 1.5, 56 | run: createRun(1) 57 | } 58 | 59 | export const decrement = { 60 | id: 'decrement-value', 61 | label: 'Decrement value', 62 | keybindings: [ 63 | // tslint:disable-next-line:no-bitwise 64 | monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KEY_J 65 | ], 66 | contextMenuGroupId: 'navigation', 67 | contextMenuOrder: 1.5, 68 | run: createRun(-1) 69 | } 70 | -------------------------------------------------------------------------------- /src/components/InpuPopover.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Popover, Input, Tooltip } from 'antd' 2 | import * as React from 'react' 3 | import { AntPlacement, AntButtonType } from '../types' 4 | 5 | interface InputPopoverProps { 6 | placement?: AntPlacement 7 | placeholder: string 8 | buttonType?: AntButtonType 9 | buttonIcon?: string 10 | buttonSize?: 'large' | 'default' | 'small' 11 | tooltipTitle?: string 12 | tooltipPlacement?: AntPlacement 13 | popoverClassName?: string 14 | onEnter: ((value: string) => void) 15 | } 16 | 17 | interface InputPopoverState { 18 | inputValue: string 19 | visible: boolean 20 | } 21 | 22 | export default class InputPopover extends React.Component { 23 | constructor(props: InputPopoverProps) { 24 | super(props) 25 | this.state = { 26 | inputValue: '', 27 | visible: false 28 | } 29 | } 30 | 31 | public render() { 32 | const content = ( 33 |
34 | ) => 39 | this.setState({ inputValue: e.target.value })} 40 | onPressEnter={e => { 41 | this.props.onEnter(this.state.inputValue) 42 | this.setState({ inputValue: '', visible: false }) 43 | }} 44 | /> 45 |
46 | ) 47 | const button = ( 48 | 55 | ) 56 | const target = this.props.tooltipTitle ? ( 57 | 58 | {button} 59 | 60 | ) : ( 61 | button 62 | ) 63 | return ( 64 | this.handleVisibleChange(visible)} 70 | > 71 | {target} 72 | 73 | ) 74 | } 75 | 76 | private handleVisibleChange(visible: boolean) { 77 | this.setState({ visible }) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "unicycle", 3 | "private": true, 4 | "version": "1.0.0", 5 | "description": "Unicycle is an Electron application built using TypeScript, React and ant.design. Its purpose is to unify the design / development cycle.", 6 | "main": "lib/main.js", 7 | "scripts": { 8 | "start": "electron .", 9 | "lint": "eslint -- .", 10 | "lint:fix": "npm run lint -- --fix", 11 | "tsc": "tsc --watch", 12 | "build": "tsc" 13 | }, 14 | "repository": "https://github.com/gimenete/unicycle", 15 | "author": "Alberto Gimeno (http://gimenete.net)", 16 | "devDependencies": { 17 | "@types/node": "^8.0.24", 18 | "electron": "^1.7.9", 19 | "eslint": "4.4.1", 20 | "eslint-config-prettier": "2.3.0", 21 | "eslint-plugin-prettier": "2.1.2", 22 | "prettier": "^1.6.1", 23 | "typescript": "^2.6.2" 24 | }, 25 | "dependencies": { 26 | "@types/codemirror": "0.0.45", 27 | "@types/finalhandler": "0.0.32", 28 | "@types/fs-extra": "^4.0.2", 29 | "@types/lodash": "^4.14.74", 30 | "@types/mousetrap": "^1.5.34", 31 | "@types/node-sass": "^3.10.32", 32 | "@types/nodegit": "^0.18.5", 33 | "@types/prettier": "^1.5.0", 34 | "@types/qrcode": "^0.8.0", 35 | "@types/react": "^16.0.31", 36 | "@types/react-dom": "^16.0.3", 37 | "@types/serve-static": "^1.13.1", 38 | "@types/sharp": "^0.17.4", 39 | "@types/source-map": "^0.5.1", 40 | "@types/ws": "^3.2.0", 41 | "antd": "^3.0.2", 42 | "camelcase": "^4.1.0", 43 | "caniuse-api": "^2.0.0", 44 | "caniuse-lite": "^1.0.30000748", 45 | "coin-hive": "^1.10.0", 46 | "css-mediaquery": "^0.1.2", 47 | "dashify": "^0.2.2", 48 | "dedent": "^0.7.0", 49 | "finalhandler": "^1.1.0", 50 | "fs-extra": "^4.0.1", 51 | "get-port": "^3.2.0", 52 | "localtunnel": "^1.8.3", 53 | "lodash": "^4.17.4", 54 | "monaco-editor": "^0.10.1", 55 | "mousetrap": "^1.6.1", 56 | "node-sass": "^4.5.3", 57 | "nodegit": "^0.20.3", 58 | "parse-import": "^2.0.0", 59 | "parse5": "^3.0.2", 60 | "postcss": "^6.0.8", 61 | "postcss-selector-parser": "^2.2.3", 62 | "qrcode": "^0.9.0", 63 | "react": "^16.2.0", 64 | "react-addons-css-transition-group": "^15.6.2", 65 | "react-dom": "^16.2.0", 66 | "react-shadow": "^16.2.0", 67 | "serve-static": "^1.13.2", 68 | "sharp": "^0.18.2", 69 | "source-map": "^0.5.6", 70 | "source-map-support": "^0.5.0", 71 | "tslint": "^5.7.0", 72 | "ws": "^3.2.0" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/navbar.tsx: -------------------------------------------------------------------------------- 1 | import { remote } from 'electron' 2 | import * as React from 'react' 3 | import BroadcastPopover from './components/BroadcastPopover' 4 | import workspace from './workspace' 5 | 6 | import { Layout, Button } from 'antd' 7 | import InputPopover from './components/InpuPopover' 8 | import errorHandler from './error-handler' 9 | 10 | const { clipboard } = remote 11 | 12 | const { Header } = Layout 13 | 14 | interface NavbarState { 15 | isCreateProjectOpen: boolean 16 | } 17 | 18 | interface NavbarProps { 19 | onAddComponent: (component: string, structure?: string) => void 20 | } 21 | 22 | class Navbar extends React.Component { 23 | constructor(props: any) { 24 | super(props) 25 | this.state = { 26 | isCreateProjectOpen: false 27 | } 28 | } 29 | 30 | public render() { 31 | return ( 32 |
33 |
34 | 35 | { 41 | // foo 42 | }} 43 | > 44 | Save 45 | 46 | 54 | 55 | 56 |
57 |
58 |
59 | { 64 | this.props.onAddComponent(value) 65 | }} 66 | > 67 | New component 68 | 69 | 70 | { 75 | this.props.onAddComponent(value, clipboard.readText()) 76 | }} 77 | > 78 | Import from Sketch 79 | 80 |
81 |
82 | ) 83 | } 84 | } 85 | 86 | export default Navbar 87 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Unicycle 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 |
16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 |
25 |
26 | 31 | 32 | 37 | 38 | 59 | 60 | 70 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /src/quick-search.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Modal, AutoComplete } from 'antd' 3 | import workspace from './workspace' 4 | 5 | const { Option } = AutoComplete 6 | 7 | interface QuickSearchProps { 8 | onSelectComponent: (component: string) => void 9 | onChangeSelection: (selection: string) => void 10 | } 11 | 12 | interface QuickSearchState { 13 | visible: boolean 14 | dataSource: any[] 15 | value: string 16 | } 17 | 18 | class QuickSearch extends React.Component { 19 | constructor(props: any) { 20 | super(props) 21 | 22 | this.state = { 23 | visible: false, 24 | dataSource: [], 25 | value: '' 26 | } 27 | 28 | Mousetrap.bind([`command+,`], (e: any) => { 29 | this.props.onChangeSelection('settings') 30 | }) 31 | 32 | Mousetrap.bind([`command+t`, `ctrl+t`], (e: any) => { 33 | this.setState({ visible: true }) 34 | const input = this.refs.searchInput as HTMLElement 35 | input.focus() 36 | 37 | const sections = [ 38 | , 39 | , 40 | , 41 | , 42 | , 43 | 44 | ] 45 | 46 | this.setState({ 47 | value: '', 48 | dataSource: sections.concat( 49 | workspace.metadata.components.map(comp => ( 50 | 51 | )) 52 | ) 53 | }) 54 | }) 55 | } 56 | 57 | public render() { 58 | return ( 59 | this.setState({ visible: false })} 64 | > 65 | this.setState({ value: e as string })} 72 | onSelect={e => { 73 | const input = e as string 74 | this.setState({ visible: false }) 75 | if (input.startsWith('c-')) { 76 | this.props.onSelectComponent(input.substring(2)) 77 | } else { 78 | this.props.onChangeSelection(input) 79 | } 80 | }} 81 | filterOption={(inputValue, option) => 82 | (option as any).props.children.toUpperCase().includes(inputValue.toUpperCase())} 83 | /> 84 | 85 | ) 86 | } 87 | } 88 | 89 | export default QuickSearch 90 | -------------------------------------------------------------------------------- /src/editors/json.ts: -------------------------------------------------------------------------------- 1 | import Editor from './' 2 | 3 | import { DiffImage, ErrorHandler, Media, State, States } from '../types' 4 | 5 | class JSONEditor extends Editor { 6 | public latestJSON: States | null 7 | 8 | constructor(element: HTMLElement, errorHandler: ErrorHandler) { 9 | super( 10 | 'data.json', 11 | element, 12 | { 13 | language: 'json' 14 | }, 15 | errorHandler 16 | ) 17 | this.latestJSON = null 18 | } 19 | 20 | public update() { 21 | try { 22 | this.latestJSON = JSON.parse(this.editor.getValue()) 23 | this.setMessages('error', []) 24 | } catch (e) { 25 | const index = +e.message.match(/\d+/) 26 | const position = this.editor.getModel().getPositionAt(index) 27 | this.setMessages('error', [ 28 | { 29 | position, 30 | text: e.message, 31 | type: 'error' 32 | } 33 | ]) 34 | } 35 | } 36 | 37 | public addState(name: string, index?: number) { 38 | const str = this.editor.getValue() 39 | const data = JSON.parse(str) as States 40 | index = index !== undefined ? index : data.length - 1 41 | const oldValue = data[index] as State 42 | const newValue: State = oldValue 43 | ? Object.assign({}, oldValue) 44 | : { name, props: {} } 45 | delete newValue.hidden 46 | delete newValue.id 47 | newValue.name = name 48 | data.splice(index + 1, 0, newValue) 49 | this.editor.setValue(JSON.stringify(data, null, 2)) 50 | } 51 | 52 | public deleteState(index: number) { 53 | const str = this.editor.getValue() 54 | const data = JSON.parse(str) as States 55 | data.splice(index, 1) 56 | this.editor.setValue(JSON.stringify(data, null, 2)) 57 | } 58 | 59 | public setMedia(media: Media, index: number) { 60 | const str = this.editor.getValue() 61 | const data = JSON.parse(str) as States 62 | if (Object.values(media).filter(value => value != null).length === 0) { 63 | delete data[index].media 64 | } else { 65 | data[index].media = media 66 | } 67 | this.editor.setValue(JSON.stringify(data, null, 2)) 68 | } 69 | 70 | public setVisibleStates(indexes: number[]) { 71 | const str = this.editor.getValue() 72 | const data = JSON.parse(str) as States 73 | data.forEach((state, index) => { 74 | data[index].hidden = !indexes.includes(index) 75 | }) 76 | this.editor.setValue(JSON.stringify(data, null, 2)) 77 | } 78 | 79 | public setDiffImage(diffImage: DiffImage, index: number) { 80 | const str = this.editor.getValue() 81 | const data = JSON.parse(str) as States 82 | data[index].diffImage = diffImage 83 | this.editor.setValue(JSON.stringify(data, null, 2)) 84 | } 85 | 86 | public deleteDiffImage(index: number) { 87 | const str = this.editor.getValue() 88 | const data = JSON.parse(str) as States 89 | // TODO: delete file 90 | delete data[index].diffImage 91 | this.editor.setValue(JSON.stringify(data, null, 2)) 92 | } 93 | } 94 | 95 | export default JSONEditor 96 | -------------------------------------------------------------------------------- /src/generators/vuejs.ts: -------------------------------------------------------------------------------- 1 | import * as parse5 from 'parse5' 2 | import * as prettier from 'prettier' 3 | 4 | import Component from '../component' 5 | import { GeneratedCode } from '../types' 6 | import { docComment } from '../utils' 7 | 8 | const dashify = require('dashify') 9 | 10 | const generateVue = ( 11 | componentNames: string[], 12 | information: Component, 13 | options?: prettier.Options 14 | ): GeneratedCode => { 15 | const { markup, data } = information 16 | const states = data.getStates() 17 | const componentName = dashify(information.name) 18 | const eventHandlers = markup.calculateEventHanlders() 19 | const typer = information.calculateTyper(true) 20 | 21 | const cloned = parse5.parseFragment(parse5.serialize(markup), { 22 | locationInfo: true 23 | }) as parse5.AST.Default.DocumentFragment 24 | 25 | const example = () => { 26 | const firstState = states[0] 27 | if (!firstState || !firstState.props) return '' 28 | const { props } = firstState 29 | let elementCode = `<${componentName}` 30 | Object.keys(props).forEach(key => { 31 | const value = props[key] 32 | const type = typeof value 33 | if (value === null || type === 'undefined') return 34 | if (type === 'string') { 35 | elementCode += `\n ${key}=${JSON.stringify(value)}` 36 | } else if (type === 'number' || type === 'boolean') { 37 | elementCode += `\n ${key}="${value}"` 38 | } else { 39 | elementCode += `\n :${key}='${JSON.stringify(value).replace( 40 | /\'/g, 41 | // tslint:disable-next-line:quotemark 42 | "\\'" 43 | )}'` 44 | } 45 | }) 46 | for (const key of eventHandlers.keys()) { 47 | elementCode += `\n :${key}="() => {}"` 48 | } 49 | elementCode += '\n/>' 50 | return elementCode 51 | } 52 | 53 | const manipulateNode = (node: parse5.AST.Default.Node) => { 54 | if (node.nodeName === '#text') { 55 | const textNode = node as parse5.AST.Default.TextNode 56 | textNode.value = textNode.value.replace(/{([^}]+)?}/g, str => `{${str}}`) 57 | } 58 | const element = node as parse5.AST.Default.Element 59 | if (!element.childNodes) { 60 | return 61 | } 62 | element.attrs.forEach(attr => { 63 | if (attr.name === '@if') { 64 | attr.name = 'v-if' 65 | } else if (attr.name.startsWith('@on')) { 66 | attr.name = 'v-on:' + attr.name.substring('@on'.length) 67 | } 68 | }) 69 | 70 | element.childNodes.forEach(manipulateNode) 71 | } 72 | 73 | cloned.childNodes.forEach(manipulateNode) 74 | 75 | const scriptCode = prettier.format( 76 | `${docComment([example()].join('\n'))} 77 | export default { 78 | props: ${typer.createVueValidation(options)} 79 | } 80 | `, 81 | options 82 | ) 83 | let code = `\n\n` 84 | code += `\n\n` 85 | code += `` 86 | 87 | return { 88 | code, 89 | path: componentName + '.vue', 90 | embeddedStyle: true 91 | } 92 | } 93 | 94 | export default generateVue 95 | -------------------------------------------------------------------------------- /src/menu-template.ts: -------------------------------------------------------------------------------- 1 | import { app } from 'electron' 2 | import { isPackaged } from './utils' 3 | import { createWindow } from './window' 4 | 5 | const isDarwin = process.platform === 'darwin' 6 | 7 | const createMenuFile = (): Electron.MenuItemConstructorOptions[] => { 8 | return [ 9 | { 10 | label: 'Open…', 11 | accelerator: 'CmdOrCtrl+O', 12 | click() { 13 | createWindow('open') 14 | } 15 | }, 16 | { 17 | label: 'New…', 18 | accelerator: 'CmdOrCtrl+N', 19 | click() { 20 | createWindow('new') 21 | } 22 | } 23 | ] 24 | } 25 | 26 | const createMenuEdit = (): Electron.MenuItemConstructorOptions[] => { 27 | const base: Electron.MenuItemConstructorOptions[] = [ 28 | { role: 'undo' }, 29 | { role: 'redo' }, 30 | { type: 'separator' }, 31 | { role: 'cut' }, 32 | { role: 'copy' }, 33 | { role: 'paste' }, 34 | { role: 'pasteandmatchstyle' }, 35 | { role: 'delete' }, 36 | { role: 'selectall' } 37 | ] 38 | if (isDarwin) { 39 | base.push( 40 | { type: 'separator' }, 41 | { 42 | label: 'Speech', 43 | submenu: [{ role: 'startspeaking' }, { role: 'stopspeaking' }] 44 | } 45 | ) 46 | } 47 | return base 48 | } 49 | 50 | const createMenuView = (): Electron.MenuItemConstructorOptions[] => { 51 | const base: Electron.MenuItemConstructorOptions[] = [{ role: 'reload' }, { role: 'forcereload' }] 52 | if (!isPackaged()) { 53 | base.push({ role: 'toggledevtools' }) 54 | } 55 | base.push( 56 | { type: 'separator' }, 57 | { role: 'resetzoom' }, 58 | { role: 'zoomin' }, 59 | { role: 'zoomout' }, 60 | { type: 'separator' }, 61 | { role: 'togglefullscreen' } 62 | ) 63 | return base 64 | } 65 | 66 | const createMenuWindow = (): Electron.MenuItemConstructorOptions[] => { 67 | return isDarwin 68 | ? [ 69 | { role: 'close' }, 70 | { role: 'minimize' }, 71 | { role: 'zoom' }, 72 | { type: 'separator' }, 73 | { role: 'front' } 74 | ] 75 | : [{ role: 'minimize' }, { role: 'close' }] 76 | } 77 | 78 | const template = [ 79 | { 80 | label: 'File', 81 | submenu: createMenuFile() 82 | }, 83 | { 84 | label: 'Edit', 85 | submenu: createMenuEdit() 86 | }, 87 | { 88 | label: 'View', 89 | submenu: createMenuView() 90 | }, 91 | { 92 | role: 'window', 93 | submenu: createMenuWindow() 94 | }, 95 | { 96 | role: 'help', 97 | submenu: [ 98 | { 99 | label: 'Learn More', 100 | click() { 101 | // require('electron').shell.openExternal('https://electron.atom.io') 102 | } 103 | } 104 | ] 105 | } 106 | ] 107 | 108 | if (isDarwin) { 109 | template.unshift({ 110 | label: app.getName(), 111 | submenu: [ 112 | { role: 'about' }, 113 | { type: 'separator' }, 114 | { role: 'services', submenu: [] }, 115 | { type: 'separator' }, 116 | { role: 'hide' }, 117 | { role: 'hideothers' }, 118 | { role: 'unhide' }, 119 | { type: 'separator' }, 120 | { role: 'quit' } 121 | ] 122 | }) 123 | } 124 | 125 | export default template 126 | -------------------------------------------------------------------------------- /src/components/BroadcastPopover.tsx: -------------------------------------------------------------------------------- 1 | import { Popover, Button, Spin, Switch } from 'antd' 2 | 3 | import * as electron from 'electron' 4 | import * as React from 'react' 5 | import server from '../server' 6 | 7 | interface BroadcastPopoverProps { 8 | position?: Position 9 | } 10 | 11 | interface BroadcastPopoverState { 12 | isVisible: boolean 13 | } 14 | 15 | export default class BroadcastPopover extends React.Component< 16 | BroadcastPopoverProps, 17 | BroadcastPopoverState 18 | > { 19 | constructor(props: BroadcastPopoverProps) { 20 | super(props) 21 | this.state = { 22 | isVisible: false 23 | } 24 | } 25 | 26 | public componentDidMount() { 27 | server.on('statusChanged', () => { 28 | this.forceUpdate() 29 | }) 30 | } 31 | 32 | public render() { 33 | const qr = server.getQR() 34 | const url = server.getURL() 35 | 36 | const content = ( 37 |
38 |

39 | { 42 | server.setBroadcast(!server.isBroadcasting()) 43 | }} 44 | />{' '} 45 | Enable broadcasting 46 |

47 |

48 | { 52 | server.setBroadcastPublicly(!server.isBroadcastingPublicly()) 53 | }} 54 | />{' '} 55 | Broadcast publicly 56 |

57 |
58 | {' '} 67 | 75 |
76 | {server.isBroadcastingPublicly() && 77 | !qr && ( 78 |
79 | 80 |
81 | )} 82 | {qr && ( 83 |

84 | 93 |

94 | )} 95 |
96 | ) 97 | 98 | return ( 99 | 105 | 112 | 113 | ) 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/components/MediaPopover.tsx: -------------------------------------------------------------------------------- 1 | import { Select, Input, Icon, Form } from 'antd' 2 | import * as React from 'react' 3 | import { Media } from '../types' 4 | 5 | const Option = Select.Option 6 | const FormItem = Form.Item 7 | 8 | interface MediaPopoverProps { 9 | media: Media 10 | onChange: (media: Media) => void 11 | } 12 | 13 | interface MediaPopoverState { 14 | isOpen: boolean 15 | mediaType: string 16 | mediaOrientation: string 17 | mediaWidth: string 18 | mediaHeight: string 19 | } 20 | 21 | export default class MediaPopover extends React.Component< 22 | MediaPopoverProps, 23 | MediaPopoverState 24 | > { 25 | constructor(props: MediaPopoverProps) { 26 | super(props) 27 | this.state = { 28 | isOpen: false, 29 | mediaType: props.media.type || '', 30 | mediaOrientation: props.media.orientation || '', 31 | mediaWidth: props.media.width || '', 32 | mediaHeight: props.media.height || '' 33 | } 34 | this.sendChanges = this.sendChanges.bind(this) 35 | } 36 | 37 | public render() { 38 | return ( 39 |
40 | 41 | 51 | 52 | 53 | 66 | 67 | 68 | 71 | this.setState({ mediaWidth: e.target.value }, this.sendChanges) 72 | } 73 | value={this.state.mediaWidth} 74 | addonAfter={ 75 | this.setState({ mediaWidth: '' })} 78 | /> 79 | } 80 | /> 81 | 82 | 83 | 86 | this.setState({ mediaHeight: e.target.value }, this.sendChanges) 87 | } 88 | value={this.state.mediaHeight} 89 | addonAfter={ 90 | this.setState({ mediaHeight: '' })} 93 | /> 94 | } 95 | /> 96 | 97 |
98 | ) 99 | } 100 | 101 | private sendChanges() { 102 | this.props.onChange({ 103 | type: this.state.mediaType || undefined, 104 | orientation: this.state.mediaOrientation || undefined, 105 | width: this.state.mediaWidth || undefined, 106 | height: this.state.mediaHeight || undefined 107 | }) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/sidebar.tsx: -------------------------------------------------------------------------------- 1 | import { Menu, Icon, Layout } from 'antd' 2 | import * as React from 'react' 3 | import * as os from 'os' 4 | 5 | import { Metadata } from './types' 6 | 7 | const { Sider } = Layout 8 | 9 | const SubMenu = Menu.SubMenu 10 | 11 | interface SidebarProps { 12 | activeSelection: string | null 13 | activeComponent: string | null 14 | metadata: Metadata 15 | onSelectComponent: (component: string) => void 16 | onDeleteComponent: (component: string) => void 17 | onChangeSelection: (selection: string) => void 18 | } 19 | 20 | class Sidebar extends React.Component { 21 | public render() { 22 | const key = os.platform() === 'darwin' ? '⌘' : 'Ctrl ' 23 | 24 | const { metadata } = this.props 25 | if (!metadata) return
26 | const { components } = metadata 27 | const { activeComponent, activeSelection } = this.props 28 | return ( 29 | 30 | { 43 | if (e.key.startsWith('c-')) { 44 | this.props.onSelectComponent(e.key.substring(2)) 45 | } else { 46 | this.props.onChangeSelection(e.key) 47 | } 48 | }} 49 | > 50 | 51 | 52 | Style Palette 53 | 54 | 55 | 56 | Assets 57 | 58 | 62 | 63 | Web components 64 | 65 | } 66 | > 67 | {components.map(component => ( 68 | 69 | { 72 | console.log('dragstart!') 73 | e.dataTransfer.setData('text/plain', component.name) 74 | e.dataTransfer.dropEffect = 'copy' 75 | }} 76 | > 77 | {component.name} 78 | 79 | 80 | ))} 81 | 82 | 83 | 84 | React Native 85 | 86 | 87 | 88 | Email templates 89 | 90 | 91 | 92 | Git log 93 | 94 | 95 | 96 | Settings 97 | 98 | 99 |

100 | Quick Search {key}+T 101 |

102 |
103 | ) 104 | } 105 | } 106 | 107 | export default Sidebar 108 | -------------------------------------------------------------------------------- /style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --black: #22282d; 3 | --gray: #dbdddd; 4 | -webkit-font-smoothing: antialiased; 5 | } 6 | 7 | html, 8 | body { 9 | height: 100%; 10 | margin: 0; 11 | user-select: none; 12 | cursor: default; 13 | } 14 | 15 | body { 16 | overflow: hidden; /* prevent scrolling */ 17 | } 18 | 19 | #app { 20 | height: 100%; 21 | } 22 | 23 | #app > div { 24 | display: flex; 25 | height: 100%; 26 | flex-direction: column; 27 | } 28 | .content { 29 | display: flex; 30 | height: 100%; 31 | width: 100%; 32 | flex-direction: row; 33 | } 34 | 35 | .ant-layout-sider { 36 | min-width: 200px; 37 | } 38 | 39 | #editors { 40 | margin: 0 10px; 41 | } 42 | 43 | #editors .editor { 44 | height: calc(100vh - 125px); 45 | } 46 | 47 | span.error { 48 | color: #f5222d; 49 | } 50 | 51 | .monaco-editor .error { 52 | background-color: rgba(255, 115, 115, 0.5); 53 | } 54 | 55 | .monaco-editor .warning { 56 | background-color: rgba(255, 201, 64, 0.5); 57 | } 58 | 59 | .monaco-editor .info { 60 | background-color: rgba(160, 198, 232, 0.5); 61 | } 62 | 63 | .monaco-editor .success { 64 | background-color: rgba(196, 223, 184, 0.5); 65 | } 66 | 67 | .glyph { 68 | cursor: pointer; 69 | } 70 | 71 | .previews-markup .preview-bar { 72 | float: right; 73 | margin-right: 5px; 74 | } 75 | 76 | .previews-markup .preview-bar > * { 77 | margin-left: 10px; 78 | } 79 | 80 | .previews-markup.show-grid .ant-collapse-content { 81 | background: url('./assets/grid.png'); 82 | background-size: 16px 16px; 83 | } 84 | 85 | body { 86 | -webkit-app-region: drag; 87 | } 88 | 89 | .navbar-brand { 90 | align-items: center; 91 | font-size: 110%; 92 | font-weight: bold; 93 | padding: 0 10px; 94 | } 95 | 96 | .no-drag, 97 | button, 98 | .button, 99 | input, 100 | .preview-content, 101 | .editor { 102 | -webkit-app-region: no-drag; 103 | } 104 | 105 | #loading { 106 | display: flex; 107 | align-items: center; 108 | justify-content: space-around; 109 | height: 100%; 110 | } 111 | 112 | body.loading #app { 113 | display: none; 114 | } 115 | 116 | .input-popover input { 117 | width: 300px; 118 | } 119 | 120 | .preview-content-overlay { 121 | position: absolute; 122 | background-color: rgba(200, 200, 200, 0.5); 123 | background-repeat: no-repeat; 124 | left: 0; 125 | top: 0; 126 | bottom: 0; 127 | right: 0; 128 | border: 10px solid transparent; 129 | pointer-events: none; 130 | } 131 | 132 | .preview-diff { 133 | padding: 5px 20px; 134 | } 135 | 136 | .drop-zone { 137 | background: #ccc; 138 | width: 100%; 139 | height: 150px; 140 | margin-bottom: 10px; 141 | display: flex; 142 | padding: 10px; 143 | align-items: center; 144 | cursor: pointer; 145 | } 146 | 147 | .drop-zone p { 148 | pointer-events: none; 149 | text-align: center; 150 | width: 100%; 151 | } 152 | 153 | .hidden-action-group .hidden-action { 154 | visibility: hidden; 155 | } 156 | 157 | .hidden-action-group:hover .hidden-action { 158 | visibility: visible; 159 | } 160 | 161 | #blank-slate { 162 | display: none; 163 | width: calc(100vw - 200px); 164 | } 165 | 166 | .content.blank-slate #previews, 167 | .content.blank-slate #editors { 168 | display: none; 169 | } 170 | 171 | .content.blank-slate #blank-slate { 172 | display: block; 173 | } 174 | 175 | #open { 176 | text-align: center; 177 | margin-top: 30vh; 178 | } 179 | 180 | .spinner-centered.spinner-centered { 181 | display: block; 182 | margin: 0 auto; 183 | } 184 | 185 | .logo { 186 | float: left; 187 | color: white; 188 | font-size: 30px; 189 | } 190 | 191 | .ant-layout-header { 192 | padding-left: 21px; 193 | padding-right: 5px; 194 | } 195 | 196 | #navbar { 197 | padding-top: 10px; 198 | } 199 | 200 | #style-palette { 201 | padding-left: 20px; 202 | max-height: 100%; 203 | overflow: auto; 204 | } 205 | 206 | .ant-collapse-content { 207 | background: whitesmoke; 208 | } 209 | -------------------------------------------------------------------------------- /src/style-palette.ts: -------------------------------------------------------------------------------- 1 | import * as sass from 'node-sass' 2 | 3 | import { 4 | PostCSSAtRule, 5 | PostCSSComment, 6 | PostCSSDeclaration, 7 | PostCSSNode, 8 | PostCSSRoot 9 | } from './types' 10 | import workspace from './workspace' 11 | import { CSS_URL_REGEXP } from './utils' 12 | 13 | const postcss = require('postcss') 14 | 15 | export interface StylePaletteEntity { 16 | name: string 17 | value: string 18 | hover?: string 19 | } 20 | 21 | class StylePalette { 22 | public readonly fonts: StylePaletteEntity[] = [] 23 | public readonly colors: StylePaletteEntity[] = [] 24 | public readonly shadows: StylePaletteEntity[] = [] 25 | public readonly animations: StylePaletteEntity[] = [] 26 | public readonly fontFaces: StylePaletteEntity[] = [] 27 | public allFontFaces: string = '' 28 | public readonly attributes = new Map() 29 | public source: string = '' 30 | public result: string = '' 31 | 32 | public setSource(source: string) { 33 | source = source || '/**/' // node-sass fails if the input is empty 34 | this.source = source 35 | 36 | const result = sass.renderSync({ 37 | data: source, 38 | sourceMap: false 39 | }) 40 | this.result = result.css.toString() 41 | const ast = postcss.parse(this.result) as PostCSSRoot 42 | 43 | this.fonts.splice(0) 44 | this.colors.splice(0) 45 | this.shadows.splice(0) 46 | this.animations.splice(0) 47 | this.attributes.clear() 48 | 49 | const prefixes = { 50 | '--font-': this.fonts, 51 | '--color-': this.colors, 52 | '--shadow-': this.shadows 53 | } 54 | 55 | const iterateNode = (node: PostCSSNode, ids: string[]) => { 56 | if (node.type === 'comment') { 57 | const comment = node as PostCSSComment 58 | const { text } = comment 59 | const match = comment.text.match(/^\/\s*@/) 60 | if (match) { 61 | const rule = text.substr(match[0].length) 62 | const index = rule.indexOf(':') 63 | if (index >= 0) { 64 | const key = rule.substring(0, index).trim() 65 | const value = rule.substring(index + 1).trim() 66 | if (key && value) { 67 | this.attributes.set(key, value) 68 | } 69 | } 70 | } 71 | // console.log('comment', comment.text) 72 | } else if (node.type === 'decl') { 73 | const decl = node as PostCSSDeclaration 74 | const { prop } = decl 75 | // Replace relative URLs to absolute URLs 76 | decl.value = decl.value.replace( 77 | CSS_URL_REGEXP, 78 | (match, p1, p2) => `url('${workspace.dir + '/assets/' + p2}')` 79 | ) 80 | for (const [prefix, collection] of Object.entries(prefixes)) { 81 | if (prop.startsWith(prefix)) { 82 | const name = prop.substr(prefix.length) 83 | collection.push({ 84 | name, 85 | value: decl.value 86 | }) 87 | } 88 | } 89 | } else if (node.type === 'atrule') { 90 | const rule = node as PostCSSAtRule 91 | if (rule.name === 'keyframes') { 92 | this.animations.push({ 93 | name: rule.params, 94 | value: rule.toString() 95 | }) 96 | } else if (rule.name === 'font-face') { 97 | if (node.nodes) { 98 | node.nodes.forEach(childNode => iterateNode(childNode, ids)) 99 | } 100 | this.fontFaces.push({ 101 | name: '', 102 | value: rule.toString() 103 | }) 104 | } 105 | } else if (node.nodes) { 106 | node.nodes.forEach(childNode => iterateNode(childNode, ids)) 107 | } 108 | this.allFontFaces = this.fontFaces.map(fontFace => fontFace.value).join('\n') 109 | } 110 | 111 | iterateNode(ast, []) 112 | 113 | // TODO: parse 114 | } 115 | } 116 | 117 | export default StylePalette 118 | -------------------------------------------------------------------------------- /src/css-striper.ts: -------------------------------------------------------------------------------- 1 | import * as crypto from 'crypto' 2 | 3 | import { 4 | componentDataAttribute, 5 | CSSMediaQuery, 6 | PostCSSAtRule, 7 | PostCSSNode, 8 | PostCSSRoot, 9 | PostCSSRule, 10 | StripedCSS 11 | } from './types' 12 | 13 | const parseImport = require('parse-import') 14 | const selectorParser = require('postcss-selector-parser') 15 | 16 | const CSS_PLACEHOLDER = '$$$$' 17 | const CSS_PLACEHOLDER_REGEXP = /\$\$\$\$/g 18 | 19 | interface ParseImportValue { 20 | condition: string 21 | path: string 22 | rule: string 23 | } 24 | 25 | export interface PreCSSChunk { 26 | mediaQueries: string[] 27 | css: string 28 | scopedCSS?: string 29 | component: string 30 | } 31 | 32 | const addAttribute = (selectorText: string, value: string) => { 33 | const transform = (selectors: any) => { 34 | selectors.each((selector: any) => { 35 | let node = null 36 | selector.each((n: any) => { 37 | if (n.type !== 'pseudo') node = n 38 | }) 39 | selector.insertAfter(node, selectorParser.attribute({ attribute: value })) 40 | selector.prepend(selectorParser.combinator({ value: CSS_PLACEHOLDER })) 41 | }) 42 | } 43 | return selectorParser(transform).process(selectorText).result 44 | } 45 | 46 | const mediaQueryClassName = (text: string) => { 47 | return ( 48 | 'mq-' + 49 | crypto 50 | .createHash('md5') 51 | .update(text) 52 | .digest('hex') 53 | .substr(0, 7) 54 | ) 55 | } 56 | 57 | export const stripeCSS = (component: string, ast: PostCSSRoot): StripedCSS => { 58 | const mediaQueries: { 59 | [index: string]: CSSMediaQuery 60 | } = {} 61 | const chunks: PreCSSChunk[] = [] 62 | const scopedAttribute = componentDataAttribute(component) 63 | 64 | const iterateNode = (node: PostCSSNode, ids: string[]) => { 65 | if (node.type === 'rule') { 66 | const rule = node as PostCSSRule 67 | rule.ids = ids 68 | chunks.push({ 69 | mediaQueries: ids, 70 | css: node.toString(), 71 | scopedCSS: `${addAttribute(rule.selector, scopedAttribute)} { 72 | ${node.nodes!.map(childNode => childNode.toString()).join(';\n')} 73 | }`, 74 | component 75 | }) 76 | } else if (node.type === 'atrule') { 77 | const atrule = node as PostCSSAtRule 78 | if (atrule.name === 'media') { 79 | const id = mediaQueryClassName(atrule.toString()) 80 | mediaQueries[id] = atrule.params 81 | if (node.nodes) { 82 | const arr = ids.concat(id) 83 | node.nodes.forEach(childNode => iterateNode(childNode, arr)) 84 | } 85 | } else if (atrule.name === 'import') { 86 | const values = parseImport(`@import ${atrule.params};`) as ParseImportValue[] 87 | if (values.length > 0) { 88 | // TODO 89 | // const condition = values[0].condition 90 | // console.log('@import condition', condition) 91 | } 92 | chunks.push({ 93 | mediaQueries: ids, 94 | css: node.toString(), 95 | component 96 | }) 97 | } else { 98 | chunks.push({ 99 | mediaQueries: ids, 100 | css: node.toString(), 101 | component 102 | }) 103 | } 104 | } else if (node.nodes) { 105 | node.nodes.forEach(childNode => iterateNode(childNode, ids)) 106 | } 107 | } 108 | 109 | iterateNode(ast, []) 110 | 111 | const mediaQueriesCount = Object.keys(mediaQueries).length 112 | chunks.forEach(chunk => { 113 | if (!chunk.scopedCSS) return 114 | const classes = chunk.mediaQueries.map(id => `.${id}`).join('') 115 | const repeat = '.preview-content'.repeat(mediaQueriesCount - chunk.mediaQueries.length) 116 | chunk.css = `${classes}${repeat} ${chunk.css}` 117 | chunk.scopedCSS = chunk.scopedCSS.replace(CSS_PLACEHOLDER_REGEXP, `${classes}${repeat} `) 118 | }) 119 | return { 120 | mediaQueries, 121 | chunks 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import * as prettier from 'prettier' 2 | 3 | export type CSSMediaQuery = string // conditions 4 | 5 | export interface CSSChunk { 6 | mediaQueries: string[] 7 | css: string 8 | scopedCSS?: string 9 | } 10 | 11 | export interface PostCSSPosition { 12 | column: number 13 | line: number 14 | } 15 | 16 | export interface PostCSSNode { 17 | type: 'root' | 'atrule' | 'rule' | 'decl' | 'comment' 18 | source: { 19 | input: { 20 | css: string 21 | id: string 22 | } 23 | start: PostCSSPosition 24 | end: PostCSSPosition 25 | } 26 | nodes?: PostCSSNode[] 27 | } 28 | 29 | export interface PostCSSRoot extends PostCSSNode { 30 | type: 'root' 31 | } 32 | 33 | export interface PostCSSAtRule extends PostCSSNode { 34 | type: 'atrule' 35 | name: string 36 | params: string 37 | } 38 | 39 | export interface PostCSSRule extends PostCSSNode { 40 | type: 'rule' 41 | selector: string 42 | ids?: string[] // custom: media query ids 43 | } 44 | 45 | export interface PostCSSDeclaration extends PostCSSNode { 46 | type: 'decl' 47 | prop: string 48 | value: string 49 | } 50 | 51 | export interface PostCSSComment extends PostCSSNode { 52 | type: 'comment' 53 | text: string 54 | } 55 | 56 | export interface SassResult { 57 | css: string 58 | map: sourceMap.RawSourceMap 59 | ast: PostCSSRoot 60 | } 61 | 62 | export interface Media { 63 | type?: string 64 | orientation?: string 65 | width?: string 66 | height?: string 67 | } 68 | 69 | export interface DiffImage { 70 | file: string 71 | resolution: string 72 | width: number 73 | height: number 74 | align: string 75 | adjustWidthPreview: boolean 76 | } 77 | 78 | export interface State { 79 | id?: string 80 | name: string 81 | hidden?: boolean 82 | props: { [index: string]: any } 83 | media?: Media 84 | diffImage?: DiffImage 85 | } 86 | 87 | export type States = State[] 88 | 89 | export interface GeneratedCode { 90 | path: string 91 | code: string 92 | embeddedStyle: boolean 93 | } 94 | 95 | export interface ObjectStringToString { 96 | [index: string]: string 97 | } 98 | 99 | export type ErrorHandler = (e: Error) => void 100 | 101 | export interface ComponentMetadata { 102 | name: string 103 | } 104 | 105 | export interface Metadata { 106 | components: ComponentMetadata[] 107 | general?: { 108 | prettier?: prettier.Options 109 | } 110 | web?: { 111 | dir: string 112 | framework: string 113 | style: string 114 | language: string 115 | // browserlist 116 | } 117 | reactNative?: { 118 | dir: string 119 | language: string 120 | iOS: boolean 121 | android: boolean 122 | } 123 | email?: { 124 | dir: string 125 | language: string 126 | inky: boolean 127 | } 128 | } 129 | 130 | export interface ReactAttributes { 131 | [index: string]: string | CssObject 132 | } 133 | 134 | export interface CssObject { 135 | [index: string]: string | number 136 | } 137 | 138 | export interface StripedCSS { 139 | mediaQueries: { 140 | [index: string]: CSSMediaQuery 141 | } 142 | chunks: CSSChunk[] 143 | } 144 | 145 | export interface StylePaletteEntity { 146 | name: string 147 | value: string 148 | hover?: string 149 | } 150 | 151 | export interface StylePalette { 152 | fonts: StylePaletteEntity[] 153 | colors: StylePaletteEntity[] 154 | shadows: StylePaletteEntity[] 155 | animations: StylePaletteEntity[] 156 | } 157 | 158 | export const INCLUDE_PREFIX = 'include:' 159 | 160 | export const componentDataAttribute = (name: string) => 161 | `data-unicycle-component-${name.toLocaleLowerCase()}` 162 | 163 | export type AntPlacement = 164 | | 'top' 165 | | 'left' 166 | | 'right' 167 | | 'bottom' 168 | | 'topLeft' 169 | | 'topRight' 170 | | 'bottomLeft' 171 | | 'bottomRight' 172 | | 'rightBottom' 173 | | 'rightTop' 174 | | 'leftTop' 175 | | 'leftBottom' 176 | 177 | export type AntButtonType = 'ghost' | 'primary' | 'dashed' | 'danger' | undefined 178 | -------------------------------------------------------------------------------- /src/inspector.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter = require('events') 2 | 3 | const parsePixels = (value: string | null) => parseFloat(value || '0') 4 | 5 | class Inspector extends EventEmitter { 6 | private selected: HTMLElement | null 7 | private target: HTMLElement | null 8 | private marginOverlay: HTMLElement 9 | private paddingOverlay: HTMLElement 10 | private inspecting: boolean 11 | 12 | constructor() { 13 | super() 14 | this.inspecting = false 15 | 16 | window.addEventListener('scroll', () => this.recalculate(), true) 17 | window.addEventListener('resize', () => this.recalculate(), true) 18 | 19 | const marginOverlay = (this.marginOverlay = document.createElement('div')) 20 | marginOverlay.style.backgroundColor = '#C4DFB8' 21 | marginOverlay.style.opacity = '0.8' 22 | marginOverlay.style.pointerEvents = 'none' 23 | marginOverlay.style.position = 'absolute' 24 | marginOverlay.style.zIndex = '2147483647' 25 | 26 | const paddingOverlay = (this.paddingOverlay = document.createElement('div')) 27 | paddingOverlay.style.width = '10px' 28 | paddingOverlay.style.height = '10px' 29 | paddingOverlay.style.backgroundColor = '#A0C6E8' 30 | 31 | marginOverlay.appendChild(paddingOverlay) 32 | document.body.appendChild(marginOverlay) 33 | 34 | document.addEventListener('mouseout', e => { 35 | if (!this.inspecting) return 36 | const element = e.target as HTMLElement 37 | if (!element.classList.contains('resolved')) { 38 | return 39 | } 40 | this.target = this.selected 41 | this.recalculate() 42 | }) 43 | } 44 | 45 | public recalculate() { 46 | if (!this.target) return 47 | 48 | const { marginOverlay, paddingOverlay } = this 49 | const rect = this.target.getBoundingClientRect() 50 | const computed = window.getComputedStyle(this.target) 51 | 52 | const paddingLeft = parsePixels(computed.paddingLeft) 53 | const paddingRight = parsePixels(computed.paddingRight) 54 | const paddingTop = parsePixels(computed.paddingTop) 55 | const paddingBottom = parsePixels(computed.paddingBottom) 56 | 57 | const marginLeft = parsePixels(computed.marginLeft) 58 | const marginRight = parsePixels(computed.marginRight) 59 | const marginTop = parsePixels(computed.marginTop) 60 | const marginBottom = parsePixels(computed.marginBottom) 61 | 62 | marginOverlay.style.top = rect.top - marginTop + 'px' 63 | marginOverlay.style.left = rect.left - marginLeft + 'px' 64 | marginOverlay.style.width = rect.width + marginLeft + marginRight + 'px' 65 | marginOverlay.style.height = rect.height + marginTop + marginBottom + 'px' 66 | marginOverlay.style.borderLeft = `${marginLeft}px solid #F9CC9D` 67 | marginOverlay.style.borderRight = `${marginRight}px solid #F9CC9D` 68 | marginOverlay.style.borderTop = `${marginTop}px solid #F9CC9D` 69 | marginOverlay.style.borderBottom = `${marginBottom}px solid #F9CC9D` 70 | 71 | paddingOverlay.style.marginLeft = `${paddingLeft}px` 72 | paddingOverlay.style.marginTop = `${paddingTop}px` 73 | paddingOverlay.style.width = rect.width - paddingLeft - paddingRight + 'px' 74 | paddingOverlay.style.height = rect.height - paddingTop - paddingBottom + 'px' 75 | } 76 | 77 | public startInspecting() { 78 | this.inspecting = true 79 | this.marginOverlay.style.display = 'block' 80 | this.emit('startInspecting') 81 | } 82 | 83 | public stopInspecting() { 84 | this.inspecting = false 85 | this.target = null 86 | this.marginOverlay.style.display = 'none' 87 | this.marginOverlay.style.width = '0px' 88 | this.marginOverlay.style.height = '0px' 89 | this.paddingOverlay.style.width = '0px' 90 | this.paddingOverlay.style.height = '0px' 91 | this.emit('stopInspecting') 92 | } 93 | 94 | public startInspectingElement(el: HTMLElement) { 95 | el.addEventListener('click', e => { 96 | if (this.target !== e.target) return 97 | this.selected = this.target 98 | this.emit('inspect', { target: this.target }) 99 | }) 100 | 101 | el.addEventListener('mousemove', e => { 102 | if (!this.inspecting) return 103 | const element = e.target as HTMLElement 104 | if (!element.matches('.preview-content *')) { 105 | return 106 | } 107 | this.target = element 108 | this.recalculate() 109 | }) 110 | } 111 | } 112 | 113 | export default new Inspector() 114 | -------------------------------------------------------------------------------- /src/editors/index.ts: -------------------------------------------------------------------------------- 1 | import { ErrorHandler } from '../types' 2 | import workspace from '../workspace' 3 | 4 | export interface Message { 5 | text: string 6 | type: MessageType 7 | position: monaco.Position 8 | } 9 | 10 | type MessageType = 'info' | 'warning' | 'error' | 'success' 11 | 12 | const messageColors = { 13 | error: 'rgba(255, 115, 115, 0.5)', 14 | warning: 'rgba(255, 201, 64, 0.5)', 15 | info: 'rgba(160, 198, 232, 0.5)', 16 | success: 'rgba(196, 223, 184, 0.5)' 17 | } 18 | 19 | const defaultOptions: monaco.editor.IEditorConstructionOptions = { 20 | lineNumbers: 'on', 21 | scrollBeyondLastLine: false, 22 | minimap: { enabled: false }, 23 | autoIndent: true, 24 | theme: 'vs', 25 | automaticLayout: true, 26 | fontLigatures: false, // true 27 | glyphMargin: true 28 | } 29 | 30 | class Editor { 31 | public readonly editor: monaco.editor.IStandaloneCodeEditor 32 | protected errorHandler: ErrorHandler 33 | private componentName: string 34 | private file: string 35 | private oldDecorations = new Map() 36 | private messages = new Map() 37 | private doNotTriggerEvents: boolean 38 | private dirty = false 39 | 40 | constructor( 41 | file: string, 42 | element: HTMLElement, 43 | options: monaco.editor.IEditorConstructionOptions, 44 | errorHandler: ErrorHandler 45 | ) { 46 | this.file = file 47 | this.editor = monaco.editor.create( 48 | element, 49 | Object.assign(options, defaultOptions) 50 | ) 51 | this.editor.getModel().updateOptions({ tabSize: 2 }) 52 | this.editor.onDidChangeModelContent( 53 | (e: monaco.editor.IModelContentChangedEvent) => { 54 | if (this.doNotTriggerEvents) return this.update() 55 | workspace 56 | .writeComponentFile(this.componentName, file, this.editor.getValue()) 57 | .then(() => { 58 | this.update() 59 | }) 60 | .catch(errorHandler) 61 | } 62 | ) 63 | this.editor.onDidLayoutChange(() => { 64 | if (this.dirty) { 65 | this.resetEditor() 66 | } 67 | }) 68 | 69 | this.errorHandler = errorHandler 70 | } 71 | 72 | public setDirty() { 73 | this.dirty = true 74 | } 75 | 76 | public setComponent(componentName: string) { 77 | this.componentName = componentName 78 | 79 | workspace 80 | .readComponentFile(this.file, componentName) 81 | .then(data => { 82 | // avoid race condition 83 | if (componentName === this.componentName) { 84 | this.doNotTriggerEvents = true 85 | this.editor.setValue(data) 86 | this.doNotTriggerEvents = false 87 | } 88 | }) 89 | .catch(this.errorHandler) 90 | } 91 | 92 | public cleanUpMessages(type: string) { 93 | this.editor.deltaDecorations(this.oldDecorations.get(type) || [], []) 94 | this.oldDecorations.set(type, []) 95 | this.messages.set(type, []) 96 | } 97 | 98 | public setMessages(key: string, messages: Message[]) { 99 | this.messages.set(key, messages) 100 | this.oldDecorations.set( 101 | key, 102 | this.editor.deltaDecorations( 103 | this.oldDecorations.get(key) || [], 104 | messages.map(message => { 105 | const { type } = message 106 | const color = messageColors[type] 107 | return { 108 | range: new monaco.Range( 109 | message.position.lineNumber, 110 | message.position.column, 111 | message.position.lineNumber, 112 | message.position.column 113 | ), 114 | options: { 115 | isWholeLine: true, 116 | className: type, 117 | glyphMarginClassName: 'glyph ' + type, 118 | hoverMessage: message.text, 119 | glyphMarginHoverMessage: message.text, 120 | overviewRuler: { 121 | color, 122 | darkColor: color, 123 | position: monaco.editor.OverviewRulerLane.Full 124 | } 125 | } 126 | } 127 | }) 128 | ) 129 | ) 130 | } 131 | 132 | public update() { 133 | // overwrite in child classes 134 | } 135 | 136 | public scrollDown() { 137 | const lines = this.editor.getModel().getLineCount() 138 | this.editor.revealLine(lines) 139 | } 140 | 141 | private resetEditor() { 142 | for (const [key, messages] of this.messages.entries()) { 143 | this.setMessages(key, messages) 144 | } 145 | this.dirty = false 146 | } 147 | } 148 | 149 | export default Editor 150 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import * as EventEmitter from 'events' 2 | import * as http from 'http' 3 | import * as path from 'path' 4 | import * as fs from 'fs-extra' 5 | import * as QRCode from 'qrcode' 6 | import * as WebSocket from 'ws' 7 | import workspace from './workspace' 8 | import { CSS_URL_REGEXP } from './utils' 9 | 10 | import * as finalhandler from 'finalhandler' 11 | import * as serveStatic from 'serve-static' 12 | 13 | const localtunnel = require('localtunnel') 14 | 15 | const html = fs.readFileSync(path.join(__dirname, '../broadcast.html')) 16 | 17 | class Server extends EventEmitter { 18 | private server: http.Server | null = null 19 | private tunnel: any = null 20 | private qr: string | null = null 21 | 22 | public getQR() { 23 | return this.qr 24 | } 25 | 26 | public isBroadcasting() { 27 | return this.server !== null 28 | } 29 | 30 | public isBroadcastingPublicly() { 31 | return this.tunnel !== null 32 | } 33 | 34 | public getURL() { 35 | return this.isBroadcastingPublicly() ? this.getPublicURL() : this.getLocalURL() 36 | } 37 | 38 | public getLocalURL() { 39 | return this.server ? `http://127.0.0.1:${this.server.address().port}` : null 40 | } 41 | 42 | public getPublicURL() { 43 | return this.tunnel ? this.tunnel.url : null 44 | } 45 | 46 | public setBroadcast(enabled: boolean) { 47 | if (!enabled) { 48 | if (this.server) { 49 | this.server.close() 50 | this.server = null 51 | } 52 | this.emit('statusChanged') 53 | return 54 | } 55 | this.server = http.createServer((req, res) => { 56 | const serve = serveStatic(path.join(workspace.dir, 'assets'), { 57 | index: false 58 | }) 59 | // TODO: favicon 60 | if (req.url !== '/') { 61 | return serve(req as any, res as any, finalhandler(req, res)) 62 | } 63 | res.setHeader('Content-Type', 'text/html; charset=utf-8') 64 | res.end(html) 65 | }) 66 | this.server.listen(0, (err: any) => { 67 | if (err) { 68 | return console.log('something bad happened', err) 69 | } 70 | console.log(`server is listening on ${this.getLocalURL()}`) 71 | 72 | const server = this.server! 73 | const wss = new WebSocket.Server({ server }) 74 | 75 | workspace.on('previewUpdated', () => { 76 | console.log('preview updated!!') 77 | setTimeout(() => { 78 | wss.clients.forEach(client => { 79 | client.send(JSON.stringify(this.getContent())) 80 | }) 81 | }, 100) // shadow dom updates seem to not be immediate 82 | }) 83 | 84 | wss.on('connection', ws => { 85 | ws.on('message', message => { 86 | console.log('received: %s', message) 87 | }) 88 | 89 | ws.send(JSON.stringify(this.getContent())) 90 | }) 91 | this.emit('statusChanged') 92 | }) 93 | } 94 | 95 | public setBroadcastPublicly(enabled: boolean) { 96 | if (!enabled) { 97 | if (this.tunnel) { 98 | this.tunnel.close() 99 | this.tunnel = null 100 | } 101 | this.qr = null 102 | this.emit('statusChanged') 103 | return 104 | } 105 | if (!this.server) { 106 | return console.error('Cannot broadcast if server is not running') 107 | } 108 | this.tunnel = localtunnel(this.server.address().port, (err: any, tunnel: any) => { 109 | if (err) return console.error(err) // TODO 110 | if (!this.tunnel) return 111 | 112 | QRCode.toDataURL(this.tunnel.url, (error, value) => { 113 | if (error) return console.error(err) 114 | this.qr = value 115 | this.emit('statusChanged') 116 | }) 117 | this.emit('statusChanged') 118 | }) 119 | 120 | this.tunnel.on('close', () => { 121 | console.error('tunnel closed') 122 | this.tunnel = null 123 | this.emit('statusChanged') 124 | }) 125 | this.emit('statusChanged') 126 | } 127 | 128 | private getContent() { 129 | const markup = Array.from(document.querySelectorAll('.broadcast, .broadcast-shadow .resolved')) 130 | if (markup.length === 0) { 131 | return [ 132 | { 133 | html: `

Nothing selected

` 134 | } 135 | ] 136 | } 137 | return markup.map(el => ({ 138 | html: (el.shadowRoot || el).innerHTML.replace(CSS_URL_REGEXP, (match, p1, p2) => { 139 | if (!p2.startsWith(workspace.dir)) return match 140 | return `url('${p2.substring((workspace.dir + '/assets').length)}')` 141 | }) 142 | })) 143 | } 144 | } 145 | 146 | export default new Server() 147 | -------------------------------------------------------------------------------- /unicycle.sketchplugin/Contents/Sketch/export.cocoascript: -------------------------------------------------------------------------------- 1 | var onRun = function (context) { 2 | const sketch = context.api() 3 | 4 | function createTempFolder() { 5 | var guid = [[NSProcessInfo processInfo] globallyUniqueString] 6 | var path = "/tmp/com.bomberstudios.sketch-commands/" + guid 7 | [[NSFileManager defaultManager] createDirectoryAtPath:path withIntermediateDirectories:true attributes:nil error:nil] 8 | return path 9 | } 10 | 11 | const tempFolder = createTempFolder() 12 | 13 | function exportSlice(layer, format, info) { 14 | const ancestry = MSImmutableLayerAncestry.ancestryWithMSLayer_(layer.sketchObject) 15 | const rect = MSSliceTrimming.trimmedRectForLayerAncestry_(ancestry) 16 | if (layer.sketchObject.isMasked()) { 17 | info.frame.x = Number(rect.origin.x) 18 | info.frame.y = Number(rect.origin.y) 19 | info.frame.width = Number(rect.size.width) 20 | info.frame.height = Number(rect.size.height) 21 | } 22 | 23 | const slice = MSExportRequest.new() 24 | slice.rect = rect 25 | slice.scale = 1 26 | slice.includeArtboardBackground = true 27 | slice.configureForLayer(ancestry) 28 | const filename = tempFolder + '/slice.' + format 29 | context.document.saveArtboardOrSlice_toFile_(slice, filename) 30 | if (format === 'svg') { 31 | return String(NSString.stringWithContentsOfFile(filename)) 32 | } else { 33 | const data = NSData.dataWithContentsOfFile(filename); 34 | const base64 = [data base64EncodedStringWithOptions:0]; 35 | return String(base64) 36 | } 37 | } 38 | 39 | /* 40 | log(sketch.api_version) 41 | log(sketch.version) 42 | log(sketch.build) 43 | log(sketch.full_version) 44 | var documentName = context.document.displayName() 45 | log('The current document is named: ' + documentName) 46 | */ 47 | 48 | const document = sketch.selectedDocument 49 | const selectedLayers = document.selectedLayers 50 | 51 | const data = [] 52 | 53 | function cssToObject(arr) { 54 | return arr.reduce((obj, str) => { 55 | if (str.startsWith('/*')) return obj 56 | const parts = str.split(':') 57 | if (parts.length > 1) { 58 | let value = parts.slice(1).join(':').trim() 59 | if (value.endsWith(';')) value = value.substring(0, value.length - 1) 60 | obj[parts[0].trim()] = value 61 | } 62 | return obj 63 | }, {}) 64 | } 65 | 66 | function appendLayerInformation(arr, layer) { 67 | if (!layer.frame) return 68 | // see https://github.com/abynim/Sketch-Headers/blob/master/Headers/MSLayer.h 69 | const sketchObject = layer.sketchObject 70 | const frame = layer.frame 71 | const info = { 72 | name: String(layer.name), 73 | frame: { x: frame.x, y: frame.y, width: frame.width, height: frame.height }, 74 | css: cssToObject(sketchObject.CSSAttributes()), 75 | layout: { 76 | hasFixedHeight: !!sketchObject.hasFixedHeight(), 77 | hasFixedWidth: !!sketchObject.hasFixedWidth(), 78 | hasFixedBottom: !!sketchObject.hasFixedBottom(), 79 | hasFixedTop: !!sketchObject.hasFixedTop(), 80 | hasFixedRight: !!sketchObject.hasFixedRight(), 81 | hasFixedLeft: !!sketchObject.hasFixedLeft() 82 | }, 83 | children: [] 84 | } 85 | const allShapes = (layer) => { 86 | if (layer.isShape) return true 87 | if (layer.isGroup) { 88 | let all = true 89 | layer.iterate((subLayer, i) => { 90 | all = all && allShapes(subLayer) 91 | }) 92 | return all 93 | } 94 | return false 95 | } 96 | if (layer.isGroup && allShapes(layer)) { 97 | info.svg = exportSlice(layer, 'svg', info) 98 | } else if (layer.isGroup) { 99 | layer.iterate((layer, i) => { 100 | appendLayerInformation(info.children, layer) 101 | }) 102 | } else if (layer.isText) { 103 | info.text = String(layer.text) 104 | info.textAlign = layer.alignment 105 | } else if (layer.isShape && !sketchObject.isPartOfClippingMask()) { 106 | info.svg = exportSlice(layer, 'svg', info) 107 | } else if (layer.isImage) { 108 | info.image = exportSlice(layer, 'png', info) 109 | } 110 | arr.push(info) 111 | } 112 | 113 | selectedLayers.iterate((layer, i) => { 114 | appendLayerInformation(data, layer) 115 | }) 116 | 117 | 118 | var pasteBoard = [NSPasteboard generalPasteboard] 119 | [pasteBoard declareTypes:[NSArray arrayWithObject:NSPasteboardTypeString] owner:nil] 120 | [pasteBoard setString:JSON.stringify(data, null, 2) forType:NSPasteboardTypeString] 121 | 122 | // TODO: https://developer.apple.com/documentation/appkit/nsworkspace/1535886-open 123 | } 124 | -------------------------------------------------------------------------------- /src/app.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as ReactDOM from 'react-dom' 3 | 4 | import BlankSlate from './blank-slate' 5 | import Editors from './editors' 6 | import Sidebar from './sidebar' 7 | import Navbar from './navbar' 8 | import OpenPage from './open' 9 | import Previews from './previews' 10 | import StylePaletteView from './style-palette-view' 11 | import GitLog from './git-log' 12 | import Assets from './assets' 13 | import Settings from './settings' 14 | import QuickSearch from './quick-search' 15 | import workspace from './workspace' 16 | 17 | import { Layout } from 'antd' 18 | const { Content } = Layout 19 | 20 | import './server' 21 | 22 | interface AppState { 23 | mode: 'opening' | 'loading' | 'loaded' 24 | activeSelection: string | null 25 | activeComponent: string | null 26 | } 27 | 28 | class App extends React.Component { 29 | constructor(props: any) { 30 | super(props) 31 | 32 | this.state = { 33 | mode: 'opening', 34 | activeComponent: null, 35 | activeSelection: null 36 | } 37 | 38 | workspace.on('projectLoaded', () => { 39 | this.setState({ mode: 'loaded' }) 40 | }) 41 | } 42 | 43 | public render() { 44 | if (this.state.mode === 'opening') return 45 | const { activeComponent, activeSelection } = this.state 46 | const className = this.state.activeComponent ? '' : 'blank-slate' 47 | const onSelectComponent = (component: string) => 48 | this.setState({ 49 | activeComponent: component, 50 | activeSelection: 'component' 51 | }) 52 | const onChangeSelection = (selection: string) => { 53 | this.setState({ 54 | activeSelection: selection, 55 | activeComponent: null 56 | }) 57 | } 58 | return ( 59 | 60 | { 62 | workspace.addComponent(component, structure).then(() => { 63 | this.setState({ 64 | activeComponent: component, 65 | activeSelection: 'component' 66 | }) 67 | }) 68 | }} 69 | /> 70 | 71 | 72 | { 78 | workspace.deleteComponent(component).then(() => { 79 | if (this.state.activeComponent === component) { 80 | this.setState({ activeComponent: null }) 81 | } 82 | }) 83 | }} 84 | onChangeSelection={onChangeSelection} 85 | /> 86 | {activeSelection === 'component' && 87 | activeComponent && ( 88 |
96 |
97 | 98 |
99 |
100 | 101 |
102 |
103 | )} 104 | {!activeSelection && ( 105 |
106 | 107 |
108 | )} 109 | {activeSelection === 'style-palette' && } 110 | {activeSelection === 'assets' && } 111 | {activeSelection === 'git-log' && } 112 | {activeSelection === 'settings' && } 113 | {activeSelection === 'react-native' && ( 114 |
115 |

Work in progress

116 |

Here you will design React Native screens

117 |
118 | )} 119 | {activeSelection === 'email-templates' && ( 120 |
121 |

Work in progress

122 |

123 | Here you will design email templates and simulate how they look in different email 124 | clients 125 |

126 |
127 | )} 128 |
129 |
130 | ) 131 | } 132 | } 133 | 134 | ReactDOM.render(React.createElement(App, {}), document.getElementById('app')) 135 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Unicycle 2 | 3 | Unicycle is an Electron application built using TypeScript, React and ant.design. Its purpose is to unify the design / development cycle. 4 | 5 | Unicycle allows you to *create, live edit and test presentational components and export them to different frameworks* (React and Vue.js by now). Each component has three different parts: 6 | 7 | - Markup: the markup, written in HTML with special attributes for conditional writing and loops 8 | - Style: SCSS for the component 9 | - Tests: JSON structure where you can define example values of the props the component accepts 10 | 11 | ![Main window](/screenshots/main.png?raw=true "Main window") 12 | 13 | ## Benefits 14 | 15 | - A designer with knowledge of web technologies can create presentational components without having to install a full development environment. 16 | - A frontend developer will obtain fully working code that can integrate directly into the app without having to spend hours trying to mimic a design 17 | 18 | ## Code coverage 19 | 20 | Since Unicycle knows all the possible tests/states of a component it is able to provide you: 21 | 22 | - Markup coverage. If an HTML element is never renderer you'll get noticed in the code editor 23 | - SCSS coverage. If a selector is never used, you'll get noticed in the code editor 24 | - State coverage (WIP). If a value in your test data / state is not used, you'll get noticed in the code editor 25 | 26 | ![Code coverage](/screenshots/code-coverage.png?raw=true "Code coverage") 27 | 28 | ## Emulate browser capabilities 29 | 30 | Unicycle is able to emulate media capabilities (e.g. media=print), orientation media queries,... So you can test all scenarios without leaving the app. 31 | 32 | ## Import from Sketch 33 | 34 | Unicycle provides a Sketch plugin to export a selection and convert it to a component. The result is not perfect but the CSS and the HTML structure is optimal and semantic to be as simple as possible to fine tune. 35 | 36 | ![Import from Sketch](/screenshots/import-from-sketch.png?raw=true "Import from Sketch") 37 | 38 | ## Remote testing 39 | 40 | Unicycle provides an internal HTTP server that you can enable so you can test your live preview in any browser, including mobile phones. 41 | 42 | ![Remote testing](/screenshots/remote-testing.png?raw=true "Remote testing") 43 | 44 | ## Style palette 45 | 46 | Unicycle encourages to use a design system. Thus, it provides tools for creating a global stylesheet for defining colors, shadows, fonts and animations. And like everything in Unicycle, you can edit it and see the changes in real time. 47 | 48 | ![Style palette](/screenshots/style-palette.png?raw=true "Style palette") 49 | 50 | # Installing and running 51 | 52 | - Clone the repository 53 | - Run `npm install` 54 | - Run `./build-sass.sh` (required to rebuild native dependencies with electron as target and not Node.js) 55 | - Run `npm run build` (transpiles the TypeScript to JavaScript) 56 | - Run `npm start` 57 | 58 | # Markup templating engine 59 | 60 | ## String interpolation 61 | 62 | You can output values by using curly braces (`{expression}`). The content is a JavaScript expression. For example: 63 | 64 | ```html 65 | {author.fullname} 66 | ``` 67 | 68 | ## Dynamic attributes 69 | 70 | You can dynamically set an HTML attribute by prefixing it with `:`. The value of the attribute will be evaluated as a JavaScript expression. 71 | 72 | ```html 73 | 74 | ``` 75 | 76 | Another, more advanced, example: 77 | 78 | ```html 79 |
80 | 81 |
82 | ``` 83 | 84 | ## Conditionals 85 | 86 | You can implement conditional rendering by using the special `@if` attribute. Example: 87 | 88 | ```html 89 |

90 | Renders the paragraph element if author is null 91 |

92 | ``` 93 | 94 | ## Loops 95 | 96 | ```html 97 |
98 |
{message.text}
99 |
100 | ``` 101 | 102 | ## Including other components 103 | 104 | For including a component into another component use `...` 105 | 106 | 107 | # Roadmap 108 | 109 | - Generate React Native code. It will require to support XML as markup instead of HTML and re-think the way CSS is handled. 110 | - Support designing email templates through [Inky](https://foundation.zurb.com/emails/docs/inky.html) 111 | - Git integration. Right now there is initial code for that. The idea is to allow designers to contribute directly to git repositories to further improve the collaboration between designers and developers. 112 | 113 | # Status of the project 114 | 115 | This app is a mature experiment. It mostly works, but many options are not implemented yet (e.g. all the exporting options, support for React Native and support for email templates) or are basically a proof of concept (git integration). Also a few things could change in a backwards incompatible way in the near future, so don't. 116 | -------------------------------------------------------------------------------- /src/assets.tsx: -------------------------------------------------------------------------------- 1 | import { Icon, Breadcrumb, Button } from 'antd' 2 | import { Card } from 'antd' 3 | import * as React from 'react' 4 | import * as fs from 'fs-extra' 5 | import * as path from 'path' 6 | import * as url from 'url' 7 | 8 | import * as electron from 'electron' 9 | const { shell } = electron.remote 10 | 11 | import workspace from './workspace' 12 | import errorHandler from './error-handler' 13 | 14 | const { Meta } = Card 15 | 16 | interface Asset { 17 | name: string 18 | isDirectory: boolean 19 | isImage: boolean 20 | stats: fs.Stats[] 21 | } 22 | 23 | interface AssetsState { 24 | path: string[] 25 | assets: Asset[] 26 | } 27 | 28 | class Assets extends React.Component { 29 | constructor(props: any) { 30 | super(props) 31 | 32 | this.state = { 33 | path: [], 34 | assets: [] 35 | } 36 | } 37 | 38 | public componentDidMount() { 39 | this.calculateAssets([]) 40 | } 41 | 42 | public render() { 43 | const partialPath: string[] = [] 44 | return ( 45 |
54 |
55 |
56 | 62 |
63 | 64 | Home 65 | 66 | this.calculateAssets([])}>assets 67 | 68 | {this.state.path.map(comp => { 69 | partialPath.push(comp) 70 | const thisPath = partialPath.slice(0) 71 | return ( 72 | 73 | this.calculateAssets(thisPath)}>{comp} 74 | 75 | ) 76 | })} 77 | 78 |
79 | 80 |
90 | {this.state.assets.map(asset => ( 91 |
{ 93 | if (asset.isDirectory) this.calculateAssets(this.state.path.concat(asset.name)) 94 | }} 95 | > 96 | 108 | {this.generatePreview(asset)} 109 |
110 | } 111 | > 112 | 113 | 114 |
115 | ))} 116 |
117 |
118 | ) 119 | } 120 | 121 | private async calculateAssets(pathComponents: string[]) { 122 | try { 123 | const dir = path.join(workspace.dir, 'assets', ...pathComponents) 124 | const paths = await fs.readdir(dir) 125 | const assets = (await Promise.all( 126 | paths.map(async (item): Promise => { 127 | const stats = await fs.stat(path.join(dir, item)) 128 | return { 129 | name: item, 130 | isDirectory: stats.isDirectory(), 131 | isImage: ['.jpg', '.png'].includes(path.extname(item)), 132 | stats: [stats] 133 | } 134 | }) 135 | )).sort((a, b) => { 136 | if (a.isDirectory && !b.isDirectory) return Number.MIN_SAFE_INTEGER 137 | if (!a.isDirectory && b.isDirectory) return Number.MAX_SAFE_INTEGER 138 | return a.name.localeCompare(b.name) 139 | }) 140 | this.setState({ assets, path: pathComponents }) 141 | } catch (error) { 142 | errorHandler(error) 143 | } 144 | } 145 | 146 | private generatePreview(asset: Asset) { 147 | if (asset.isImage) 148 | return ( 149 | 157 | ) 158 | return 159 | } 160 | } 161 | 162 | export default Assets 163 | -------------------------------------------------------------------------------- /src/style-palette-view.tsx: -------------------------------------------------------------------------------- 1 | import errorHandler from './error-handler' 2 | import workspace from './workspace' 3 | 4 | import { Tabs } from 'antd' 5 | import * as React from 'react' 6 | 7 | const TabPane = Tabs.TabPane 8 | 9 | class StylePaletteView extends React.Component { 10 | private editor: monaco.editor.IStandaloneCodeEditor 11 | 12 | public componentWillUnmount() { 13 | this.editor.dispose() 14 | } 15 | 16 | public componentDidMount() { 17 | workspace.emit('previewUpdated') 18 | } 19 | 20 | public componentDidUpdate() { 21 | workspace.emit('previewUpdated') 22 | } 23 | 24 | public render() { 25 | const palette = workspace.palette 26 | const previewText = palette.attributes.get('font-preview-text') || 'Hello world' 27 | const commonStyle = `.style-palette-name { 28 | opacity: 0.7; 29 | } 30 | 31 | .style-palette-value { 32 | opacity: 0.7; 33 | } 34 | ` 35 | return ( 36 |
44 |
{ 47 | if (!element) return 48 | this.initEditor(element) 49 | }} 50 | style={{ 51 | height: 'calc(100vh - 90px)' 52 | }} 53 | /> 54 |
55 | { 58 | setTimeout(() => { 59 | workspace.emit('previewUpdated') 60 | }, 300) 61 | }} 62 | > 63 | 64 |
65 | 68 | {palette.fonts.map((font, i) => ( 69 |
70 |
{font.name}
71 |
{previewText}
72 |
73 | ))} 74 |
75 |
76 | 77 |
78 | 79 | {palette.colors.map((color, i) => ( 80 |
81 |
{color.name}
82 |
89 |

{color.value}

90 |
91 | ))} 92 |
93 | 94 | 95 |
96 | 114 | {palette.shadows.map((shadow, i) => ( 115 |
116 |
{shadow.name}
117 |
124 |
125 | ))} 126 |
127 | 128 | 129 |
130 | 133 | {palette.animations.map(animation => ( 134 |
135 |
{animation.name}
136 |
144 |
145 | ))} 146 |
147 | 148 | 149 |
150 |
151 | ) 152 | } 153 | 154 | private initEditor(element: HTMLElement): any { 155 | if (this.editor) return 156 | const palette = workspace.palette 157 | this.editor = monaco.editor.create(element, { 158 | language: 'scss', 159 | value: palette.source, 160 | lineNumbers: 'on', 161 | scrollBeyondLastLine: false, 162 | minimap: { enabled: false }, 163 | autoIndent: true, 164 | theme: 'vs', 165 | automaticLayout: true 166 | }) 167 | this.editor.onDidChangeModelContent(e => { 168 | const str = this.editor.getValue() 169 | 170 | workspace 171 | .writeStylePalette(str) 172 | .then(() => { 173 | this.forceUpdate() 174 | }) 175 | .catch(errorHandler) 176 | }) 177 | } 178 | } 179 | 180 | export default StylePaletteView 181 | -------------------------------------------------------------------------------- /src/generators/react.ts: -------------------------------------------------------------------------------- 1 | import * as parse5 from 'parse5' 2 | import * as prettier from 'prettier' 3 | 4 | import Component from '../component' 5 | import css2obj from '../css2obj' 6 | import { GeneratedCode, INCLUDE_PREFIX } from '../types' 7 | import { docComment, toReactAttributeName, toReactEventName, uppercamelcase } from '../utils' 8 | 9 | const camelcase = require('camelcase') 10 | 11 | const generateReact = ( 12 | componentNames: string[], 13 | information: Component, 14 | options?: prettier.Options 15 | ): GeneratedCode => { 16 | const { data, markup } = information 17 | const states = data.getStates() 18 | const componentName = uppercamelcase(information.name) 19 | const eventHandlers = markup.calculateEventHanlders() 20 | const typer = information.calculateTyper(true) 21 | 22 | const keys = states.reduce((set: Set, value) => { 23 | Object.keys(value.props).forEach(key => set.add(key)) 24 | return set 25 | }, new Set()) 26 | 27 | const example = () => { 28 | const firstState = states[0] 29 | if (!firstState || !firstState.props) return '' 30 | const { props } = firstState 31 | let codeExample = `class MyContainer extends Component { 32 | render() { 33 | return <${componentName}` 34 | Object.keys(props).forEach(key => { 35 | const value = props[key] 36 | if (typeof value === 'string') { 37 | codeExample += ` ${key}=${JSON.stringify(value)}` 38 | } else if (typeof value === 'number') { 39 | codeExample += ` ${key}=${value}` 40 | } else if (typeof value === 'boolean') { 41 | if (value) { 42 | codeExample += ` ${key}` 43 | } else { 44 | codeExample += ` ${key}={${value}}` 45 | } 46 | } else { 47 | codeExample += ` ${key}={${JSON.stringify(value)}}` 48 | } 49 | }) 50 | for (const key of eventHandlers.keys()) { 51 | codeExample += ` ${key}={() => {}}` 52 | } 53 | codeExample += '/> } }' 54 | return prettier.format(codeExample, options) 55 | } 56 | 57 | const exampleCode = example() 58 | const lines = ['This file was generated automatically. Do not change it. Use composition instead'] 59 | if (exampleCode) { 60 | lines.push('') 61 | lines.push('This is an example of how to use the generated component:') 62 | lines.push('') 63 | lines.push(exampleCode) 64 | } 65 | const comment = docComment(lines.join('\n')) 66 | 67 | const dependencies = new Set() 68 | 69 | const renderNode = (node: parse5.AST.Default.Node) => { 70 | if (node.nodeName === '#text') { 71 | const textNode = node as parse5.AST.Default.TextNode 72 | return textNode.value 73 | } 74 | const element = node as parse5.AST.Default.Element 75 | if (!element.childNodes) return '' 76 | const calculateElementName = () => { 77 | const elemName = node.nodeName 78 | if (!node.nodeName.startsWith(INCLUDE_PREFIX)) { 79 | return { 80 | name: elemName, 81 | custom: false 82 | } 83 | } 84 | const name = camelcase(elemName.substring(INCLUDE_PREFIX.length)) 85 | const canonicalName = componentNames.find(comp => comp.toLowerCase() === name) || name 86 | dependencies.add(canonicalName) 87 | return { 88 | name: canonicalName, 89 | custom: true 90 | } 91 | } 92 | const toString = () => { 93 | const elementInfo = calculateElementName() 94 | let elementCode = `<${elementInfo.name}` 95 | element.attrs.forEach(attr => { 96 | if (attr.name.startsWith(':')) return 97 | if (attr.name.startsWith('@on')) { 98 | const required = attr.name.endsWith('!') 99 | const eventName = toReactEventName( 100 | attr.name.substring(1, attr.name.length - (required ? 1 : 0)) 101 | ) 102 | if (eventName) { 103 | elementCode += ` ${eventName}={${attr.value}}` 104 | } 105 | } 106 | if (attr.name.startsWith('@')) return 107 | const name = elementInfo.custom ? attr.name : toReactAttributeName(attr.name) 108 | if (name === 'style') { 109 | elementCode += ` ${name}={${JSON.stringify(css2obj(attr.value))}}` 110 | } else if (name) { 111 | elementCode += ` ${name}="${attr.value}"` 112 | } 113 | }) 114 | element.attrs.forEach(attr => { 115 | if (!attr.name.startsWith(':')) return 116 | const attrName = attr.name.substring(1) 117 | const name = elementInfo.custom ? attrName : toReactAttributeName(attrName) 118 | if (name) { 119 | const expression = attr.value 120 | elementCode += ` ${name}={${expression}}` 121 | } 122 | }) 123 | elementCode += '>' 124 | element.childNodes.forEach(childNode => (elementCode += renderNode(childNode))) 125 | elementCode += `` 126 | return elementCode 127 | } 128 | let basicMarkup = toString() 129 | 130 | const ifs = element.attrs.find(attr => attr.name === '@if') 131 | const loop = element.attrs.find(attr => attr.name === '@loop') 132 | const as = element.attrs.find(attr => attr.name === '@as') 133 | if (loop && as) { 134 | basicMarkup = `{(${loop.value}).map((${as.value}, i) => ${basicMarkup})}` // TODO: key attr 135 | } 136 | if (ifs) { 137 | basicMarkup = `{(${ifs.value}) && (${basicMarkup})}` 138 | } 139 | return basicMarkup 140 | } 141 | 142 | const renderReturn = renderNode(markup.getDOM().childNodes[0]) 143 | 144 | let code = `${comment} 145 | import React from 'react'; 146 | import PropTypes from 'prop-types'; // eslint-disable-line no-unused-vars 147 | import './styles.css'; 148 | ${[...dependencies].map(dep => `import ${dep} from '../${dep}';`).join('\n')} 149 | 150 | const ${componentName} = (props) => {` 151 | 152 | if (keys.size > 0) { 153 | code += `const {${Array.from(keys) 154 | .concat(Array.from(eventHandlers.keys())) 155 | .join(', ')}} = props;` 156 | } 157 | 158 | code += 'return ' + renderReturn 159 | code += '}\n\n' 160 | code += typer.createPropTypes(`${componentName}.propTypes`) 161 | code += '\n\n' 162 | code += 'export default ' + componentName 163 | try { 164 | return { 165 | code: prettier.format(code, options), 166 | path: componentName + '/index.jsx', 167 | embeddedStyle: false 168 | } 169 | } catch (err) { 170 | console.log('code', code) 171 | console.error(err) 172 | throw err 173 | } 174 | } 175 | 176 | export default generateReact 177 | -------------------------------------------------------------------------------- /src/components/DiffImagePopover.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Switch } from 'antd' 2 | import electron = require('electron') 3 | import * as React from 'react' 4 | import * as sharp from 'sharp' 5 | import errorHandler from '../error-handler' 6 | import workspace from '../workspace' 7 | 8 | import { DiffImage } from '../types' 9 | 10 | const { BrowserWindow, dialog } = electron.remote 11 | 12 | interface DiffImagePopoverProps { 13 | componentName: string 14 | diffImage?: DiffImage 15 | onChange: (image: DiffImage) => void 16 | onDelete: () => void 17 | } 18 | 19 | interface DiffImagePopoverState { 20 | isOpen: boolean 21 | resolution: string 22 | align: string 23 | path?: string 24 | width?: number 25 | height?: number 26 | adjustWidthPreview?: boolean 27 | } 28 | 29 | const defaultState: DiffImagePopoverState = { 30 | isOpen: false, 31 | resolution: '@2x', 32 | align: 'center', 33 | width: 0, 34 | height: 0, 35 | path: undefined, 36 | adjustWidthPreview: false 37 | } 38 | 39 | export default class DiffImagePopover extends React.Component< 40 | DiffImagePopoverProps, 41 | DiffImagePopoverState 42 | > { 43 | constructor(props: DiffImagePopoverProps) { 44 | super(props) 45 | const { diffImage } = props 46 | this.state = diffImage 47 | ? { 48 | isOpen: false, 49 | resolution: diffImage.resolution, 50 | align: diffImage.align, 51 | width: diffImage.width, 52 | height: diffImage.height, 53 | path: diffImage.file, 54 | adjustWidthPreview: diffImage.adjustWidthPreview 55 | } 56 | : defaultState 57 | } 58 | 59 | public sendChanges() { 60 | this.props.onChange({ 61 | file: this.state.path!, 62 | resolution: this.state.resolution!, 63 | width: this.state.width!, 64 | height: this.state.height!, 65 | align: this.state.align!, 66 | adjustWidthPreview: !!this.state.adjustWidthPreview 67 | }) 68 | } 69 | 70 | public handleFile(fullPath: string) { 71 | const image = sharp(fullPath) 72 | image 73 | .metadata() 74 | .then(data => { 75 | return workspace 76 | .copyComponentFile(this.props.componentName, fullPath) 77 | .then(basename => { 78 | this.setState( 79 | { 80 | path: basename, 81 | width: data.width, 82 | height: data.height 83 | }, 84 | () => this.sendChanges() 85 | ) 86 | }) 87 | }) 88 | .catch(errorHandler) 89 | } 90 | 91 | public handleDrop(e: React.DragEvent) { 92 | e.preventDefault() 93 | // If dropped items aren't files, reject them 94 | const dt = e.dataTransfer 95 | if (dt.items) { 96 | // Use DataTransferItemList interface to access the file(s) 97 | for (const item of Array.from(dt.items)) { 98 | const file = item.getAsFile() 99 | if (file) { 100 | return this.handleFile(file.path) 101 | } 102 | } 103 | } else { 104 | // Use DataTransfer interface to access the file(s) 105 | for (const item of Array.from(dt.files)) { 106 | return this.handleFile(item.path) 107 | } 108 | } 109 | } 110 | 111 | public render() { 112 | const renderResolutionButton = (value: string) => { 113 | return ( 114 | 122 | ) 123 | } 124 | 125 | const renderAlignButton = (text: string, value: string) => { 126 | return ( 127 | 135 | ) 136 | } 137 | 138 | return ( 139 |
140 |
this.handleDrop(e)} 143 | onDragEnter={e => e.preventDefault()} 144 | onDragOver={e => e.preventDefault()} 145 | onClick={e => { 146 | const paths = dialog.showOpenDialog( 147 | BrowserWindow.getFocusedWindow(), 148 | { 149 | properties: ['openFile'], 150 | filters: [ 151 | { 152 | name: 'Images', 153 | extensions: ['jpg', 'jpeg', 'tiff', 'png', 'gif'] 154 | } 155 | ] 156 | } 157 | ) 158 | if (paths.length > 0) { 159 | this.handleFile(paths[0]) 160 | } 161 | }} 162 | > 163 |

Click or drop an image here

164 |
165 | {this.state.path && ( 166 |
167 |
168 | 169 | {renderResolutionButton('@1x')} 170 | {renderResolutionButton('@2x')} 171 | 172 |
173 |
174 |
175 | {renderAlignButton('↖︎', 'top left')} 176 | {renderAlignButton('↑', 'top')} 177 | {renderAlignButton('↗︎', 'top right')} 178 |
179 |
180 | {renderAlignButton('←', 'left')} 181 | {renderAlignButton('◉', 'center')} 182 | {renderAlignButton('→', 'right')} 183 |
184 |
185 | {renderAlignButton('↙︎', 'bottom left')} 186 | {renderAlignButton('↓', 'bottom')} 187 | {renderAlignButton('↘︎', 'bottom right')} 188 |
189 |
190 |
191 |
192 |
193 | 196 | this.setState( 197 | { 198 | adjustWidthPreview: !this.state.adjustWidthPreview 199 | }, 200 | () => this.sendChanges() 201 | ) 202 | } 203 | />{' '} 204 | Adjust preview width to image width 205 |
206 |
207 |
208 |
209 | 215 |
216 |
217 | )} 218 |
219 | ) 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /src/preview-render.tsx: -------------------------------------------------------------------------------- 1 | import * as parse5 from 'parse5' 2 | import * as React from 'react' 3 | import Component from './component' 4 | import css2obj from './css2obj' 5 | import { evaluateExpression } from './eval' 6 | import { 7 | INCLUDE_PREFIX, 8 | ObjectStringToString, 9 | ReactAttributes, 10 | State, 11 | componentDataAttribute 12 | } from './types' 13 | import { toReactAttributeName } from './utils' 14 | import workspace from './workspace' 15 | 16 | const renderComponent = ( 17 | info: Component, 18 | state: State, 19 | rootNodeProperties: React.CSSProperties | null, 20 | components: Set, 21 | errorHandler: (component: string, position: monaco.Position, text: string) => void, 22 | componentKey: string | number | null 23 | ): React.ReactNode => { 24 | components.add(info.name) 25 | const renderNode = ( 26 | data: {}, 27 | node: parse5.AST.Default.Node, 28 | key: string | number | null, 29 | additionalStyles: React.CSSProperties | null, 30 | additionalDataAttribute: string | null, 31 | isRoot: boolean 32 | ): React.ReactNode => { 33 | const nodeCounter = node as any 34 | nodeCounter.visits = (nodeCounter.visits || 0) + 1 35 | const locationJSON = (location: parse5.MarkupData.ElementLocation) => 36 | JSON.stringify({ 37 | cmp: info.name, 38 | ln: location.line, 39 | c: location.col, 40 | eln: location.endTag !== undefined ? location.endTag.line : location.line, 41 | ec: location.endTag !== undefined ? location.endTag.col : location.col 42 | }) 43 | try { 44 | if (node.nodeName === '#text') { 45 | const textNode = node as parse5.AST.Default.TextNode 46 | return textNode.value.replace(/{([^}]+)?}/g, str => { 47 | return evaluateExpression(str.substring(1, str.length - 1), data) 48 | }) 49 | } 50 | const element = node as parse5.AST.Default.Element 51 | if (!element.childNodes) return undefined 52 | const ifs = element.attrs.find(attr => attr.name === '@if') 53 | if (ifs) { 54 | const result = evaluateExpression(ifs.value, data) 55 | if (!result) { 56 | nodeCounter.visits-- 57 | return undefined 58 | } 59 | } 60 | const loop = element.attrs.find(attr => attr.name === '@loop') 61 | const as = element.attrs.find(attr => attr.name === '@as') 62 | if (loop && as) { 63 | const collection = evaluateExpression(loop.value, data) as any[] 64 | if (!Array.isArray(collection)) { 65 | throw new Error('Trying to loop a non-array') 66 | } 67 | if (collection.length === 0) { 68 | nodeCounter.visits-- 69 | return undefined 70 | } 71 | const template = Object.assign({}, node, { 72 | attrs: element.attrs.filter(attr => !attr.name.startsWith('@')) 73 | }) 74 | return collection.map((obj, i) => 75 | renderNode( 76 | Object.assign({}, data, { [as.value]: obj }), 77 | template, 78 | i, 79 | null, 80 | additionalDataAttribute, 81 | false 82 | ) 83 | ) 84 | } 85 | if (node.nodeName.startsWith(INCLUDE_PREFIX)) { 86 | const componentName = node.nodeName.substring(INCLUDE_PREFIX.length) 87 | const componentInfo = workspace.getComponent(componentName) 88 | const props = element.attrs.reduce( 89 | (elementProps, attr) => { 90 | if (attr.name.startsWith(':')) { 91 | const name = attr.name.substring(1) 92 | const expression = attr.value 93 | elementProps[name] = evaluateExpression(expression, data) 94 | } else { 95 | // TODO: convert to type 96 | elementProps[attr.name] = attr.value 97 | } 98 | return elementProps 99 | }, 100 | {} as any 101 | ) 102 | // TODO: validate props 103 | const componentState: State = { 104 | name: 'Included', 105 | props 106 | } 107 | // TODO: key? 108 | return renderComponent(componentInfo, componentState, null, components, errorHandler, key) 109 | } 110 | const attrs: ReactAttributes = element.attrs 111 | .filter(attr => !attr.name.startsWith(':') && !attr.name.startsWith('@')) 112 | .reduce( 113 | (obj, attr) => { 114 | const name = toReactAttributeName(attr.name) 115 | if (name) { 116 | obj[name] = attr.value 117 | } 118 | return obj 119 | }, 120 | {} as ObjectStringToString 121 | ) 122 | if (key !== null) { 123 | attrs.key = String(key) 124 | } 125 | element.attrs.forEach(attr => { 126 | if (!attr.name.startsWith(':')) return 127 | const name = attr.name.substring(1) 128 | const expression = attr.value 129 | const fname = toReactAttributeName(name) 130 | if (fname) { 131 | attrs[fname] = evaluateExpression(expression, data) 132 | // TODO if attrs.style is dynamic it MUST be an object 133 | } 134 | }) 135 | if (attrs.style && typeof attrs.style === 'string') { 136 | attrs.style = css2obj(attrs.style as string) 137 | } 138 | const location = element.__location 139 | if (location) { 140 | attrs['data-location'] = locationJSON(location) 141 | } 142 | attrs.style = Object.assign({}, attrs.style || {}, additionalStyles) 143 | if (isRoot) { 144 | attrs['data-unicycle-component-root'] = '' 145 | } 146 | if (additionalDataAttribute) { 147 | attrs[additionalDataAttribute] = '' 148 | } 149 | const childNodes = element.childNodes.map((childNode, i) => 150 | renderNode(data, childNode, i, null, additionalDataAttribute, false) 151 | ) 152 | return React.createElement.apply( 153 | null, 154 | new Array(node.nodeName, attrs).concat(childNodes) 155 | ) 156 | } catch (err) { 157 | const element = node as parse5.AST.Default.Element 158 | if (node && element.__location) { 159 | errorHandler( 160 | info.name, 161 | new monaco.Position(element.__location.line, element.__location.col), 162 | err.message 163 | ) 164 | } 165 | return ( 166 | 179 | Error: {err.message} 180 | 181 | ) 182 | } 183 | } 184 | const rootNode = info.markup.getRootNode() 185 | // Make all root properties in all states available even if they are not defined 186 | // This way you can do @if="rootVarThatIsNotInAllStates" 187 | const initialData = info.data.getStates().reduce((obj, st) => { 188 | for (const prop of Object.keys(st.props)) { 189 | obj[prop] = undefined 190 | } 191 | return obj 192 | }, {} as any) 193 | Object.assign(initialData, state.props) 194 | 195 | return renderNode( 196 | initialData, 197 | rootNode, 198 | componentKey, 199 | rootNodeProperties, 200 | componentDataAttribute(info.name), 201 | true 202 | ) 203 | } 204 | 205 | export default renderComponent 206 | -------------------------------------------------------------------------------- /src/workspace.ts: -------------------------------------------------------------------------------- 1 | import * as EventEmitter from 'events' 2 | import * as fse from 'fs-extra' 3 | import * as Git from 'nodegit' 4 | import * as path from 'path' 5 | import * as prettier from 'prettier' 6 | import Component from './component' 7 | import reactGenerator from './generators/react' 8 | import vueGenerator from './generators/vuejs' 9 | import sketch from './sketch' 10 | import StylePalette from './style-palette' 11 | import { ErrorHandler, GeneratedCode, Metadata, States } from './types' 12 | import { CSS_URL_REGEXP } from './utils' 13 | 14 | const { Repository } = Git 15 | 16 | const metadataFile = 'unicycle.json' 17 | const paletteFile = 'palette.scss' 18 | const sourceDir = 'components' 19 | 20 | class Workspace extends EventEmitter { 21 | public dir: string 22 | public metadata: Metadata 23 | public components = new Map() 24 | public palette = new StylePalette() 25 | private repo: Git.Repository 26 | 27 | public async loadProject(dir: string) { 28 | this.dir = dir 29 | this.metadata = JSON.parse(await this.readFile(metadataFile)) 30 | this.palette.setSource(await this.readFile(paletteFile)) 31 | this.emit('projectLoaded') 32 | } 33 | 34 | public async createProject(dir: string) { 35 | const initialMetadata: Metadata = { 36 | components: [] 37 | } 38 | await Promise.all([ 39 | fse.mkdir(path.join(dir, 'assets')), 40 | fse.writeFile(path.join(dir, metadataFile), JSON.stringify(initialMetadata)), 41 | fse.writeFile(path.join(dir, paletteFile), ''), 42 | fse.mkdirp(path.join(dir, sourceDir)) 43 | ]) 44 | this.loadProject(dir) 45 | } 46 | 47 | public async addComponent(name: string, structure?: string) { 48 | const initial = structure 49 | ? await sketch(structure) 50 | : { 51 | markup: '
\n \n
', 52 | style: '' 53 | } 54 | const initialState = JSON.stringify([{ name: 'A test', props: {} }] as States, null, 2) 55 | await fse.mkdir(path.join(this.dir, sourceDir, name)) 56 | await Promise.all([ 57 | this.writeFile(path.join(sourceDir, name, 'index.html'), initial.markup), 58 | this.writeFile(path.join(sourceDir, name, 'styles.scss'), initial.style), 59 | this.writeFile(path.join(sourceDir, name, 'data.json'), initialState) 60 | ]) 61 | this.metadata.components.push({ name }) 62 | await this.saveMetadata() 63 | } 64 | 65 | public async deleteComponent(name: string) { 66 | await fse.remove(path.join(this.dir, sourceDir, name)) 67 | this.metadata.components = this.metadata.components.filter(component => component.name !== name) 68 | await this.saveMetadata() 69 | this.components.delete(name) 70 | } 71 | 72 | public readComponentFile(file: string, component: string): Promise { 73 | return this.readFile(path.join(sourceDir, component, file)) 74 | } 75 | 76 | public readComponentFileSync(file: string, component: string): string { 77 | return this.readFileSync(path.join(sourceDir, component, file)) 78 | } 79 | 80 | public readFile(relativePath: string): Promise { 81 | // TODO: prevent '..' in relativePath 82 | const fullPath = path.join(this.dir, relativePath) 83 | return fse.readFile(fullPath, 'utf8') 84 | } 85 | 86 | public readFileSync(relativePath: string): string { 87 | // TODO: prevent '..' in relativePath 88 | const fullPath = path.join(this.dir, relativePath) 89 | return fse.readFileSync(fullPath, 'utf8') 90 | } 91 | 92 | public async writeComponentFile(componentName: string, file: string, data: string) { 93 | const fullPath = path.join(sourceDir, name, componentName, file) 94 | await this.writeFile(fullPath, data) 95 | const component = this.getComponent(componentName) 96 | if (file === 'index.html') { 97 | component.markup.setMarkup(data) 98 | } else if (file === 'data.json') { 99 | component.data.setData(data) 100 | } else if (file === 'styles.scss') { 101 | component.style.setStyle(data) 102 | } 103 | this.emit('componentUpdated') 104 | } 105 | 106 | public writeFile(relativePath: string, data: string): Promise { 107 | // TODO: prevent '..' in relativePath 108 | const fullPath = path.join(this.dir, relativePath) 109 | return fse.writeFile(fullPath, data) 110 | } 111 | 112 | public writeStylePalette(source: string) { 113 | this.palette.setSource(source) 114 | return this.writeFile(paletteFile, source) 115 | } 116 | 117 | public async copyComponentFile(componentName: string, fullPath: string): Promise { 118 | const basename = path.basename(fullPath) 119 | await fse.copy(fullPath, path.join(this.dir, sourceDir, componentName, basename)) 120 | return basename 121 | } 122 | 123 | public pathForComponentFile(componentName: string, basename: string) { 124 | return path.join(this.dir, sourceDir, componentName, basename) 125 | } 126 | 127 | public getComponent(name: string): Component { 128 | let info = this.components.get(name) 129 | if (info) return info 130 | info = this.loadComponent(name) 131 | this.components.set(name, info) 132 | return info 133 | } 134 | 135 | public loadComponent(name: string): Component { 136 | const markup = this.readComponentFileSync('index.html', name) 137 | const data = this.readComponentFileSync('data.json', name) 138 | const style = this.readComponentFileSync('styles.scss', name) 139 | 140 | return new Component(name, markup, style, data) 141 | } 142 | 143 | public async generate(errorHandler: ErrorHandler) { 144 | try { 145 | const generators: { 146 | [index: string]: ( 147 | componentNames: string[], 148 | information: Component, 149 | options?: prettier.Options 150 | ) => GeneratedCode 151 | } = { 152 | react: reactGenerator, 153 | vue: vueGenerator 154 | } 155 | 156 | const componentNames = this.metadata.components.map(comp => comp.name) 157 | 158 | const generalOptions = this.metadata.general || {} 159 | const exportOptions = this.metadata.web || { framework: 'react', dir: null } 160 | if (!exportOptions.dir) return 161 | 162 | const outDir = exportOptions.dir // path.join(this.dir, exportOptions.dir) 163 | await fse.mkdirp(outDir) 164 | console.log('outDir', outDir) 165 | for (const component of this.metadata.components) { 166 | console.log('+', component.name) 167 | const info = this.loadComponent(component.name) 168 | const prettierOptions = generalOptions.prettier 169 | const code = generators[exportOptions.framework || 'react']( 170 | componentNames, 171 | info, 172 | prettierOptions 173 | ) 174 | const fullPath = path.join(outDir, code.path) 175 | const fullPathDir = path.dirname(fullPath) 176 | await fse.mkdirp(fullPathDir) 177 | console.log('-', fullPath) 178 | await fse.writeFile(fullPath, code.code) 179 | const css = info.style.getCSS().source.replace(CSS_URL_REGEXP, (match, p1, p2) => { 180 | if (!p2.startsWith(this.dir)) return match 181 | console.log('...................................') 182 | return `url('${p2.substring((this.dir + '/assets').length)}')` 183 | }) 184 | if (!code.embeddedStyle) { 185 | console.log('-', fullPath) 186 | await fse.writeFile( 187 | path.join(fullPathDir, 'styles.css'), 188 | prettier.format(css.toString(), { 189 | parser: 'postcss', 190 | ...prettierOptions 191 | }) 192 | ) 193 | } 194 | } 195 | await fse.writeFile(path.join(outDir, 'index.css'), this.palette.result) 196 | await fse.copy(path.join(this.dir, 'assets'), outDir) 197 | } catch (err) { 198 | errorHandler(err) 199 | } 200 | } 201 | 202 | public async getRepository() { 203 | if (this.repo) return this.repo 204 | 205 | const repoDir = await this.findRepositoryDir() 206 | if (!repoDir) return null 207 | 208 | this.repo = await Repository.open(repoDir) 209 | return this.repo 210 | } 211 | 212 | public async saveMetadata() { 213 | // TODO: make dirs relative to project dir 214 | await this.writeFile(metadataFile, JSON.stringify(this.metadata, null, 2)) 215 | this.emit('metadataChanged') 216 | } 217 | 218 | private async findRepositoryDir() { 219 | let currentDir = this.dir 220 | while (true) { 221 | if (await fse.pathExists(path.join(currentDir, '.git'))) { 222 | return currentDir 223 | } 224 | currentDir = path.resolve(currentDir, '..') 225 | return null 226 | } 227 | } 228 | } 229 | 230 | export default new Workspace() 231 | -------------------------------------------------------------------------------- /src/editors.tsx: -------------------------------------------------------------------------------- 1 | import { Tabs } from 'antd' 2 | 3 | import * as EventEmitter from 'events' 4 | import * as os from 'os' 5 | import * as React from 'react' 6 | 7 | import Editor, { Message } from './editors/index' 8 | import JSONEditor from './editors/json' 9 | import MarkupEditor from './editors/markup' 10 | import StyleEditor from './editors/style' 11 | 12 | import autocomplete from './autocomplete' 13 | import actions from './editor-actions' 14 | import errorHandler from './error-handler' 15 | import inspector from './inspector' 16 | import workspace from './workspace' 17 | import { inheritedProperties } from './common' 18 | 19 | const { TabPane } = Tabs 20 | 21 | autocomplete() 22 | 23 | const editorIds = ['markup', 'style', 'data'] 24 | 25 | editorIds.forEach((id, i) => { 26 | Mousetrap.bind([`command+${i + 1}`, `ctrl+${i + 1}`], (e: any) => { 27 | Editors.selectEditor(id) 28 | }) 29 | }) 30 | 31 | class EditorsEventBus extends EventEmitter {} 32 | 33 | interface EditorsProps { 34 | activeComponent: string 35 | } 36 | 37 | interface EditorsState { 38 | selectedTabId: string 39 | } 40 | 41 | class Editors extends React.Component { 42 | public static eventBus = new EditorsEventBus() 43 | public static markupEditor: MarkupEditor | null 44 | public static styleEditor: StyleEditor | null 45 | public static dataEditor: JSONEditor | null 46 | 47 | public static selectEditor(selectedTabId: string) { 48 | Editors.eventBus.emit('selectEditor', selectedTabId) 49 | } 50 | 51 | public static addState(name: string) { 52 | if (!this.dataEditor) return 53 | const lines = this.dataEditor.editor.getModel().getLineCount() 54 | this.dataEditor.addState(name) 55 | this.selectEditor('style') 56 | this.dataEditor.scrollDown() 57 | this.dataEditor.editor.setPosition({ 58 | lineNumber: lines, 59 | column: 3 60 | }) 61 | } 62 | 63 | private static editors: Map = new Map() 64 | 65 | constructor(props: any) { 66 | super(props) 67 | this.state = { 68 | selectedTabId: 'markup' 69 | } 70 | inspector.on('stopInspecting', () => { 71 | this.stopInspecting() 72 | }) 73 | inspector.on('inspect', (data: any) => { 74 | const element = data.target as HTMLElement 75 | this.inspect(element) 76 | }) 77 | 78 | Editors.eventBus.on('selectEditor', (selectedTabId: string) => { 79 | this.handleTabChange(selectedTabId) 80 | }) 81 | } 82 | 83 | public render() { 84 | const key = os.platform() === 'darwin' ? '⌘' : 'Ctrl ' 85 | return ( 86 | this.handleTabChange(selectedTabId)} 90 | > 91 | 92 |
{ 95 | const editor = Editors.markupEditor!.editor 96 | const component = e.dataTransfer.getData('text/plain') 97 | if (component) { 98 | editor.trigger('keyboard', 'type', { 99 | text: `\n$1` 100 | }) 101 | } else { 102 | for (const item of Array.from(e.dataTransfer.items)) { 103 | const file = item.getAsFile() 104 | if (file) { 105 | // TODO 106 | editor.trigger('keyboard', 'type', { 107 | text: file.path 108 | }) 109 | } 110 | } 111 | } 112 | }} 113 | onDragEnter={e => e.preventDefault()} 114 | onDragOver={e => { 115 | console.log('drag over!!') 116 | e.preventDefault() 117 | e.stopPropagation() 118 | e.dataTransfer.dropEffect = 'copy' 119 | 120 | const editor = Editors.markupEditor!.editor 121 | editor.focus() 122 | const position = editor.getTargetAtClientPoint(e.clientX, e.clientY).position 123 | editor.setPosition(position) 124 | }} 125 | ref={element => element && this.initMarkupEditor(element)} 126 | /> 127 | 128 | 129 |
element && this.initStyleEditor(element)} /> 130 | 131 | 132 |
element && this.initDataEditor(element)} /> 133 | 134 | 135 | ) 136 | } 137 | 138 | public componentDidMount() { 139 | this.updateEditors() 140 | } 141 | 142 | public componentDidUpdate() { 143 | this.updateEditors() 144 | } 145 | 146 | public componentWillUnmount() { 147 | for (const editor of Editors.editors.values()) { 148 | editor.editor.dispose() 149 | } 150 | Editors.editors.clear() 151 | Editors.markupEditor = null 152 | Editors.styleEditor = null 153 | Editors.dataEditor = null 154 | } 155 | 156 | private updateEditors() { 157 | Editors.editors.forEach(editor => { 158 | editor.setComponent(this.props.activeComponent) 159 | }) 160 | } 161 | 162 | private inspect(element: HTMLElement) { 163 | const location = element.getAttribute('data-location') 164 | if (!location) { 165 | Editors.styleEditor!.setMessages('inspector', []) 166 | return 167 | } 168 | const locationData = JSON.parse(location) 169 | const lineNumber = locationData.ln as number 170 | const column = locationData.c as number 171 | const endLineNumber = locationData.eln as number 172 | const endColumn = locationData.ec as number 173 | Editors.markupEditor!.editor.revealLinesInCenterIfOutsideViewport(lineNumber, endLineNumber) 174 | Editors.markupEditor!.editor.setPosition({ 175 | lineNumber, 176 | column 177 | }) 178 | if (endLineNumber !== undefined && endColumn !== undefined) { 179 | Editors.markupEditor!.editor.setSelection({ 180 | startLineNumber: lineNumber, 181 | startColumn: column, 182 | endLineNumber, 183 | endColumn 184 | }) 185 | } 186 | this.focusVisibleEditor() 187 | 188 | const matches = (elem: HTMLElement, selector: string): HTMLElement | null => { 189 | if (elem.matches('.preview-content')) return null 190 | if (elem.matches(selector)) return elem 191 | if (elem.parentElement) return matches(elem.parentElement, selector) 192 | return null 193 | } 194 | 195 | const { activeComponent } = this.props 196 | const component = workspace.getComponent(activeComponent) 197 | const messages: Message[] = [] 198 | component.style.iterateSelectors(info => { 199 | const match = matches(element, info.selector) 200 | if (match) { 201 | const type = match === element ? 'success' : 'info' 202 | const text = match === element ? 'Matching selector' : 'Parent matching selector' 203 | info.children.forEach(mapping => { 204 | const affects = 205 | match === element || inheritedProperties.includes(mapping.declaration.prop) 206 | if (affects) { 207 | messages.push({ 208 | position: new monaco.Position(mapping.line, mapping.column), 209 | text, 210 | type 211 | }) 212 | } 213 | }) 214 | messages.push({ 215 | position: new monaco.Position(info.mapping.line, info.mapping.column), 216 | text, 217 | type 218 | }) 219 | } 220 | }) 221 | Editors.styleEditor!.setMessages('inspector', messages) 222 | } 223 | 224 | private focusVisibleEditor() { 225 | const editor = Editors.editors.get(this.state.selectedTabId) 226 | if (editor) { 227 | editor.editor.focus() 228 | } 229 | } 230 | 231 | private stopInspecting() { 232 | Editors.styleEditor!.cleanUpMessages('inspector') 233 | } 234 | 235 | private handleTabChange(selectedTabId: string) { 236 | this.setState({ selectedTabId }) 237 | const editor = Editors.editors.get(selectedTabId) 238 | if (editor) { 239 | editor.setDirty() 240 | } 241 | } 242 | 243 | private initMarkupEditor(element: HTMLDivElement) { 244 | if (Editors.markupEditor) return 245 | const editor = new MarkupEditor(element, errorHandler) 246 | Editors.markupEditor = editor 247 | Editors.editors.set('markup', editor) 248 | actions.forEach(action => editor.editor.addAction(action)) 249 | 250 | // Hack to get the first previews.render() with the editor loaded and ready 251 | let first = true 252 | editor.editor.onDidChangeModelContent(() => { 253 | if (first) { 254 | workspace.emit('componentUpdated') 255 | first = false 256 | } 257 | }) 258 | } 259 | 260 | private initStyleEditor(element: HTMLDivElement) { 261 | if (Editors.styleEditor) return 262 | const editor = new StyleEditor(element, errorHandler) 263 | Editors.styleEditor = editor 264 | Editors.editors.set('style', editor) 265 | actions.forEach(action => editor.editor.addAction(action)) 266 | } 267 | 268 | private initDataEditor(element: HTMLDivElement) { 269 | if (Editors.dataEditor) return 270 | const editor = new JSONEditor(element, errorHandler) 271 | Editors.dataEditor = editor 272 | Editors.editors.set('data', editor) 273 | actions.forEach(action => editor.editor.addAction(action)) 274 | } 275 | } 276 | 277 | export default Editors 278 | -------------------------------------------------------------------------------- /src/settings.tsx: -------------------------------------------------------------------------------- 1 | import { Collapse, Form, Icon, Input, Radio, Switch } from 'antd' 2 | import * as React from 'react' 3 | import errorHandler from './error-handler' 4 | import workspace from './workspace' 5 | 6 | import electron = require('electron') 7 | 8 | const { BrowserWindow, dialog } = electron.remote 9 | 10 | const FormItem = Form.Item 11 | const { Button: RadioButton, Group: RadioGroup } = Radio 12 | const { TextArea, Search } = Input 13 | const { Panel } = Collapse 14 | 15 | const formItemLayout = { 16 | labelCol: { 17 | xs: { span: 12 }, 18 | sm: { span: 4 } 19 | }, 20 | wrapperCol: { 21 | xs: { span: 24 }, 22 | sm: { span: 16 } 23 | } 24 | } 25 | 26 | class WebComponentsSettings extends React.Component { 27 | public render() { 28 | return ( 29 |
30 | 35 | { 40 | const paths = dialog.showOpenDialog(BrowserWindow.getFocusedWindow(), { 41 | properties: ['openDirectory'], 42 | defaultPath: workspace.metadata.web && workspace.metadata.web.dir 43 | }) 44 | if (!paths || paths.length === 0) return 45 | workspace.metadata.web = Object.assign({}, workspace.metadata.web) 46 | workspace.metadata.web.dir = paths[0] 47 | workspace 48 | .saveMetadata() 49 | .then(() => workspace.generate(errorHandler)) 50 | .catch(errorHandler) 51 | }} 52 | /> 53 | 54 | 55 | 56 | React 57 | Angular 58 | Vue 59 | Web components 60 | 61 | 62 | 63 | 64 | SCSS 65 | CSS 66 | 67 | 68 | 69 | 70 | JavaScript 71 | TypeScript 72 | 73 | 74 | 79 | This must be a valid .browserslistrc file. Read more about browserlist 80 | and its configuration:{' '} 81 | 82 | browserlist 83 | 84 | 85 | } 86 | > 87 |