├── .gitignore ├── assets ├── .DS_Store └── images │ ├── blog16.png │ ├── blog32.png │ ├── blog48.png │ ├── blog128.png │ ├── github-logo.svg │ └── chrome-logo.svg ├── screenShots ├── cache.png ├── select.png ├── 20190420-160829.png ├── 20190420-160909.png └── 20190420-161014.png ├── .babelrc ├── test ├── playground │ └── url.js └── axiosTest.js ├── src ├── components │ └── TextEditor │ │ ├── index.css │ │ └── index.js ├── pages │ ├── popup.js │ ├── options.js │ ├── editor.js │ └── background.js ├── api │ ├── mock.js │ └── index.js ├── views │ ├── editor │ │ ├── editor-bar.js │ │ ├── files.js │ │ ├── index.js │ │ └── style.css │ └── option │ │ ├── index.jsx │ │ ├── auth.jsx │ │ └── form.jsx └── utils.js ├── manifest.json ├── README.md ├── webpack.config.js ├── package.json └── note.md /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | dist 3 | node_modules 4 | -------------------------------------------------------------------------------- /assets/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bangbangde/chrome-github-blog/HEAD/assets/.DS_Store -------------------------------------------------------------------------------- /screenShots/cache.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bangbangde/chrome-github-blog/HEAD/screenShots/cache.png -------------------------------------------------------------------------------- /assets/images/blog16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bangbangde/chrome-github-blog/HEAD/assets/images/blog16.png -------------------------------------------------------------------------------- /assets/images/blog32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bangbangde/chrome-github-blog/HEAD/assets/images/blog32.png -------------------------------------------------------------------------------- /assets/images/blog48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bangbangde/chrome-github-blog/HEAD/assets/images/blog48.png -------------------------------------------------------------------------------- /screenShots/select.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bangbangde/chrome-github-blog/HEAD/screenShots/select.png -------------------------------------------------------------------------------- /assets/images/blog128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bangbangde/chrome-github-blog/HEAD/assets/images/blog128.png -------------------------------------------------------------------------------- /screenShots/20190420-160829.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bangbangde/chrome-github-blog/HEAD/screenShots/20190420-160829.png -------------------------------------------------------------------------------- /screenShots/20190420-160909.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bangbangde/chrome-github-blog/HEAD/screenShots/20190420-160909.png -------------------------------------------------------------------------------- /screenShots/20190420-161014.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bangbangde/chrome-github-blog/HEAD/screenShots/20190420-161014.png -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env", "@babel/preset-react"], 3 | "plugins": ["@babel/plugin-syntax-dynamic-import", "@babel/plugin-transform-runtime"] 4 | } 5 | -------------------------------------------------------------------------------- /test/playground/url.js: -------------------------------------------------------------------------------- 1 | const url = require('url'); 2 | // 10.15.3 版本 node 才有 URL 对象 3 | console.log(url) 4 | // console.log(url.parse('https://developer.chrome.com/extensions/tabs#method-update')) 5 | // console.log(url.parse('callback://oauth-code/test?q=zcq')) 6 | -------------------------------------------------------------------------------- /src/components/TextEditor/index.css: -------------------------------------------------------------------------------- 1 | #text-editor { 2 | width: 100%; height: 100%; overflow: auto; border: none; background-color: transparent; 3 | color: #b3b3b3; font-family: Georgia,Times New Roman,Times,Songti SC,serif; 4 | padding: 20px 40px 80px; font-size: 18px; font-weight: 400; line-height: 30px; 5 | white-space: pre-wrap; box-sizing: border-box; resize: none; 6 | } 7 | #text-editor:focus {-webkit-appearance: none; outline: none;} 8 | -------------------------------------------------------------------------------- /src/pages/popup.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDom from 'react-dom'; 3 | const container = document.createElement('div'); 4 | function View(){ 5 | const styles = { 6 | info: { 7 | minWidth: '100px', 8 | margin: '24px' 9 | } 10 | } 11 | return (
正在初始化一些数据,请稍等...
) 12 | } 13 | ReactDom.render(, container); 14 | document.body.appendChild(container); 15 | -------------------------------------------------------------------------------- /src/pages/options.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDom from 'react-dom'; 3 | import { SnackbarProvider } from 'notistack'; 4 | import Option from '@/views/option'; 5 | 6 | window.background = chrome.extension.getBackgroundPage().actions; 7 | const container = document.createElement('div'); 8 | const view = ( 9 | 10 | 11 | 12 | ); 13 | ReactDom.render(view, container); 14 | document.body.appendChild(container); 15 | -------------------------------------------------------------------------------- /test/axiosTest.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | 3 | axios.interceptors.response.use(response => { 4 | console.log('interceptors', response); 5 | return response; 6 | }, error => { 7 | console.log('interceptors', error) 8 | return Promise.reject(error); 9 | }) 10 | 11 | axios({ 12 | url: 'https://www.baidu.com', 13 | transformResponse: [ 14 | function (data) { 15 | console.log('transformResponse', data) 16 | return data; 17 | } 18 | ] 19 | }) 20 | -------------------------------------------------------------------------------- /src/pages/editor.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDom from 'react-dom'; 3 | import { SnackbarProvider } from "notistack"; 4 | import Editor from '@/views/editor/index'; 5 | 6 | window.background = chrome.extension.getBackgroundPage().actions; 7 | const container = document.createElement('div'); 8 | const view = ( 9 | 10 | 11 | 12 | ); 13 | ReactDom.render(view, container); 14 | document.body.appendChild(container); 15 | -------------------------------------------------------------------------------- /src/api/mock.js: -------------------------------------------------------------------------------- 1 | import Url from "url"; 2 | 3 | const mock = { 4 | // 'get /authorizations': config => new Promise(resolve => { 5 | // resolve({ 6 | // status: 200, 7 | // data: [] 8 | // }) 9 | // }) 10 | } 11 | 12 | class Mock{ 13 | constructor(adapter) { 14 | this.adapter = adapter; 15 | } 16 | test(config){ 17 | let path = Url.parse(config.url).path; 18 | let key = `${config.method} ${path}`; 19 | if(mock[key]){ 20 | return mock[key](); 21 | }else{ 22 | return this.adapter(config) 23 | } 24 | } 25 | } 26 | export default Mock; 27 | -------------------------------------------------------------------------------- /assets/images/github-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "BlogHub", 3 | "version": "0.0.1", 4 | "manifest_version": 2, 5 | 6 | "description": "在 chrome extension 中管理你的 GitHub page,打造触手可达的写作环境", 7 | "icons": { 8 | "16": "assets/images/blog16.png", 9 | "32": "assets/images/blog32.png", 10 | "48": "assets/images/blog48.png", 11 | "128": "assets/images/blog128.png" 12 | }, 13 | 14 | "background": { 15 | "page": "background.html", 16 | "persistent": false 17 | }, 18 | "browser_action": { 19 | "default_popup": "popup.html", 20 | "default_title": "GitHub pages 伴侣" 21 | }, 22 | "options_page": "options.html", 23 | "options_ui": { 24 | "page": "options.html", 25 | "open_in_tab": false, 26 | "chrome_style": false 27 | }, 28 | "permissions": [ 29 | "tabs", "http://*/", 30 | "https://*/", 31 | "webNavigation", 32 | "activeTab", 33 | "declarativeContent", 34 | "storage" 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 在 Chrome 中编写博客并推送到 github pages💯 2 | 3 | 正在努力 coding... 4 | 5 | 非常希望有感兴趣的朋友愿意加入和我一起快乐打码 6 | 7 | 技术栈: `chrome extension` `react` `material-ui` `axios` `webpack` 8 | 9 | 另外,喜欢的小伙伴给颗Star🌟吧 10 | 11 | 当前进度: 12 | 13 | - [x] Github 授权 14 | - [x] 仓库、分支等配置项 15 | - [x] 编辑器实时预览 16 | - [x] 浏览 Github 仓库文件 17 | - [x] 实时本地缓存 18 | - [x] 粘贴图片自动上传 19 | - [ ] 完善编辑器功能 20 | - [ ] 开放用户自定义抓取文章脚本 21 | - [ ] 测试 22 | - [ ] 皮肤定制 23 | - [ ] 发布 24 | 25 | 26 | 开发预览: 27 | 28 | 1. `npm install` 29 | 2. `npm build` 30 | 3. chrome 扩展管理中加载 `dist` 目录 31 | 32 | ![auth](https://github.com/bangbangde/chrome-github-blog/blob/master/screenShots/20190420-160829.png) 33 | 34 | ![repo](https://github.com/bangbangde/chrome-github-blog/blob/master/screenShots/20190420-160909.png) 35 | 36 | ![write](https://github.com/bangbangde/chrome-github-blog/blob/master/screenShots/20190420-161014.png) 37 | 38 | ![select](https://github.com/bangbangde/chrome-github-blog/blob/master/screenShots/select.png) 39 | 40 | ![cache](https://github.com/bangbangde/chrome-github-blog/blob/master/screenShots/cache.png) 41 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const Entry = require('./build/entries'); 3 | const CleanWebpackPlugin = require('clean-webpack-plugin'); 4 | const CopyWebpackPlugin = require('copy-webpack-plugin') 5 | 6 | let entry = Entry.entries(); 7 | let htmlPlugins = Entry.htmlPlugins(); 8 | 9 | module.exports = { 10 | mode: process.env.NODE_ENV || 'development', 11 | entry, 12 | output: { 13 | filename: 'js/[name].js', 14 | path: path.resolve(__dirname, 'dist') 15 | }, 16 | module: { 17 | rules: [ 18 | { 19 | test: /\.css$/, 20 | use: ["style-loader", "css-loader"] 21 | }, 22 | { 23 | test: /\.(js|jsx)$/, 24 | exclude: /(node_modules|bower_components)/, 25 | loader: 'babel-loader' 26 | } 27 | ] 28 | }, 29 | devtool: 'inline-source-map', 30 | devServer: { 31 | contentBase: path.resolve(__dirname, 'dist') 32 | }, 33 | plugins: [ 34 | new CleanWebpackPlugin(), 35 | ...htmlPlugins, 36 | new CopyWebpackPlugin([ 37 | 'manifest.json', 38 | {from: 'assets', to: 'assets'} 39 | ],{copyUnmodified: true})// 因为每次都会清理dist,所以要强制复制所有文件 40 | ], 41 | resolve: { 42 | extensions: ['.js', '.jsx'], 43 | alias: { 44 | '@': path.resolve('src'), 45 | '@assets': path.resolve('assets') 46 | } 47 | }, 48 | optimization: { 49 | runtimeChunk: 'single', 50 | splitChunks: { 51 | // chunks: "all", 52 | cacheGroups: { 53 | vendors: { 54 | chunks: "all", 55 | test: /[\\/]node_modules[\\/]/, 56 | name: 'vendors', 57 | reuseExistingChunk: true 58 | } 59 | } 60 | } 61 | } 62 | }; 63 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chrome-github-blog", 3 | "version": "0.0.1", 4 | "description": "在 Chrome 中编写博客并推送到 github pages。", 5 | "private": true, 6 | "scripts": { 7 | "build": "webpack", 8 | "watch": "webpack --watch", 9 | "test": "echo \"Error: no test specified\" && exit 1" 10 | }, 11 | "keywords": [ 12 | "chrome extension", 13 | "blog", 14 | "markdown", 15 | "github pages" 16 | ], 17 | "author": "bangabngde", 18 | "license": "ISC", 19 | "devDependencies": { 20 | "@babel/cli": "^7.1.0", 21 | "@babel/core": "^7.1.0", 22 | "@babel/plugin-proposal-async-generator-functions": "^7.2.0", 23 | "@babel/plugin-syntax-dynamic-import": "^7.2.0", 24 | "@babel/plugin-transform-runtime": "^7.4.3", 25 | "@babel/preset-env": "^7.1.0", 26 | "@babel/preset-react": "^7.0.0", 27 | "babel-loader": "^8.0.5", 28 | "clean-webpack-plugin": "^2.0.1", 29 | "copy-webpack-plugin": "^5.0.2", 30 | "css-loader": "^2.1.1", 31 | "html-webpack-plugin": "^3.2.0", 32 | "style-loader": "^0.23.1", 33 | "webpack": "^4.29.6", 34 | "webpack-cli": "^3.3.0", 35 | "webpack-dev-server": "^3.2.1" 36 | }, 37 | "dependencies": { 38 | "@babel/runtime": "^7.4.3", 39 | "@material-ui/core": "^3.9.3", 40 | "@material-ui/icons": "^3.0.2", 41 | "axios": "^0.18.0", 42 | "js-base64": "^2.5.1", 43 | "lodash": "^4.17.11", 44 | "marked": "^0.6.2", 45 | "notistack": "^0.6.1", 46 | "rc-tree": "^1.15.2", 47 | "react": "^16.8.4", 48 | "react-dom": "^16.8.4", 49 | "yaml": "^1.5.0" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/views/editor/editor-bar.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { withStyles } from '@material-ui/core/styles'; 3 | import Avatar from '@material-ui/core/Avatar'; 4 | 5 | import CloudUpload from '@material-ui/icons/CloudUploadOutlined'; 6 | import FolderOpen from '@material-ui/icons/FolderOpen'; 7 | import Laptop from '@material-ui/icons/Laptop'; 8 | import Tooltip from "@material-ui/core/Tooltip"; 9 | import FiberNew from '@material-ui/icons/FiberNew'; 10 | 11 | class EditorBar extends React.Component { 12 | constructor(props){ 13 | super(props); 14 | this.state = {}; 15 | } 16 | 17 | render() { 18 | const { classes } = this.props; 19 | return ( 20 |
21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 |
35 |
36 | ); 37 | } 38 | } 39 | 40 | const styles = theme => ({ 41 | root: { 42 | backgroundColor: '#2e2e2e', 43 | borderColor: '#2e2e2e', 44 | padding: theme.spacing.unit 45 | }, 46 | group: { 47 | display: 'flex', 48 | alignItems: 'center', 49 | }, 50 | icon: { 51 | fontSize: 24, 52 | cursor: 'pointer', 53 | color: '#b3b3b3', 54 | margin: '0 16px', 55 | '&:hover': { 56 | color: '#4d4d4d' 57 | } 58 | } 59 | }); 60 | 61 | export default withStyles(styles)(EditorBar); 62 | -------------------------------------------------------------------------------- /src/views/option/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { withSnackbar } from 'notistack'; 3 | import CssBaseline from '@material-ui/core/CssBaseline'; 4 | import Auth from './auth'; 5 | import Form from './form'; 6 | import CircularProgress from "@material-ui/core/CircularProgress"; 7 | 8 | const styles = { 9 | globalProgress: { 10 | position: 'fixed', top: 0, right: 0, bottom: 0, left: 0, backgroundColor: 'rgba(0,0,0,.6)', 11 | display: 'flex', justifyContent: 'center', alignItems: 'center' 12 | }, 13 | main: { 14 | backgroundColor: 'white', 15 | minHeight: '230px', 16 | padding: '24px' 17 | } 18 | } 19 | 20 | class Option extends React.Component { 21 | constructor(props){ 22 | super(props); 23 | this.state = { 24 | loaded: false, 25 | authShow: false 26 | }; 27 | } 28 | 29 | showLoading(){ 30 | this.setState({ 31 | progress:
32 | 33 |
34 | }) 35 | } 36 | hideLoading(){ 37 | this.setState({ 38 | progress: null 39 | }) 40 | } 41 | 42 | componentWillMount() { 43 | this.showLoading(); 44 | background.validToken().then(res => { 45 | this.setState({ 46 | authShow: !res.success, 47 | loaded: res.success 48 | }) 49 | this.hideLoading(); 50 | }) 51 | } 52 | 53 | toast(message, variant='info'){ 54 | this.props.enqueueSnackbar(message, { 55 | variant: variant, 56 | anchorOrigin: { 57 | vertical: 'top', 58 | horizontal: 'center', 59 | } 60 | }); 61 | } 62 | 63 | onAuthSubmit(pm){ 64 | this.showLoading(); 65 | pm.then(() => { 66 | this.setState({authShow: false, loaded: true}) 67 | this.hideLoading(); 68 | }).catch( msg => { 69 | this.hideLoading(); 70 | this.toast(msg, 'warn') 71 | }); 72 | } 73 | 74 | onFormSubmit(pm){ 75 | this.showLoading(); 76 | pm.then(res => { 77 | this.hideLoading(); 78 | if(res.success){ 79 | this.toast('信息已保存', 'success') 80 | }else{ 81 | this.toast(res.message, 'error') 82 | } 83 | }).catch( e => { 84 | this.hideLoading(); 85 | this.toast(e.message, 'warn'); 86 | }); 87 | } 88 | 89 | componentDidMount() { 90 | // this.toast('componentDidMount', 'success'); 91 | } 92 | 93 | render(){ 94 | return ( 95 | 96 | 97 | {this.state.progress} 98 |
99 | {this.state.authShow && } 100 | {this.state.loaded &&
} 101 |
102 | 103 |
104 | ); 105 | } 106 | } 107 | 108 | export default withSnackbar(Option); 109 | -------------------------------------------------------------------------------- /src/api/index.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import Mock from './mock'; 3 | import { Base64 } from 'js-base64'; 4 | 5 | axios.defaults.baseURL = 'https://api.github.com'; 6 | axios.defaults.headers.common['Accept'] = 'application/vnd.github.v3+json'; 7 | const mock = new Mock(axios.defaults.adapter); 8 | axios.defaults.adapter = config => mock.test(config); 9 | axios.defaults.validateStatus = function (status) { 10 | return status < 500; // Reject only if the status code is greater than or equal to 500 11 | } 12 | axios.interceptors.response.use(res => { 13 | if(res.data && res.data.message){ 14 | res.success = false 15 | }else{ 16 | res.success = true 17 | } 18 | console.log('AXIOS response:', res.config.url, res.success?'success':'error') 19 | return res; 20 | }); 21 | export function setData(key, value) { 22 | axios[key] = value; 23 | if(key == 'token'){ 24 | axios.defaults.headers.common['Authorization'] = 'token ' + value; 25 | } 26 | } 27 | 28 | export async function createAuthorization(username, password) { 29 | return axios({ 30 | url: 'authorizations', 31 | method: 'post', 32 | auth: {username, password}, 33 | data: { 34 | "scopes": ["repo"], 35 | "note": axios.note 36 | }, 37 | }); 38 | } 39 | 40 | export async function listAuthorizations(username, password) { 41 | return axios({ 42 | url: 'authorizations', 43 | method: 'get', 44 | auth: {username, password} 45 | }); 46 | } 47 | 48 | export async function deleteAuthorization(username, password, id) { 49 | return axios({ 50 | url: 'authorizations/' + id, 51 | method: 'delete', 52 | auth: {username, password} 53 | }); 54 | } 55 | 56 | export async function getUserInfo() { 57 | return axios({ 58 | url: 'user', 59 | method: 'get' 60 | }); 61 | } 62 | 63 | export async function getRepositories() { 64 | return axios({ 65 | url: '/user/repos', 66 | method: 'get' 67 | }); 68 | } 69 | 70 | export async function getBranches(repo, branch = '') { 71 | return axios({ 72 | url: `/repos/${axios.login}/${repo}/branches/${branch}`, 73 | method: 'get' 74 | }); 75 | } 76 | 77 | export async function createRepository(name) { 78 | return axios({ 79 | url: '/user/repos', 80 | method: 'post', 81 | data:{name} 82 | }); 83 | } 84 | 85 | export async function getContents(path = '', isRaw = false) { 86 | return axios({ 87 | url: `repos/${axios.login}/${axios.repo}/contents/${path}`, 88 | method: 'get', 89 | headers: { 90 | Accept: isRaw ? 'application/vnd.github.v3.raw+json' : 'application/vnd.github.v3+json' 91 | }, 92 | param: {ref: axios.branch} 93 | }); 94 | } 95 | 96 | export async function updateContents(path, content, sha) { 97 | return axios({ 98 | url: `repos/${axios.login}/${axios.repo}/contents/${path}`, 99 | method: 'put', 100 | data: { 101 | message: axios.message, 102 | content: Base64.encode(content), 103 | branch: axios.branch, 104 | sha 105 | } 106 | }); 107 | } 108 | 109 | export async function uploadFiles(path, file) { 110 | return new Promise((resolve, reject) => { 111 | let reader = new FileReader(); 112 | reader.readAsBinaryString(file); 113 | reader.onloadend = function(ev) { 114 | if(ev.target.error){ 115 | reject(ev.target.error) 116 | }else{ 117 | resolve(btoa(ev.target.result)) 118 | } 119 | }; 120 | }).then(content => { 121 | return axios({ 122 | url: `repos/${axios.login}/${axios.repo}/contents/${path}`, 123 | method: 'put', 124 | data: { 125 | message: axios.message, 126 | content, 127 | branch: axios.branch, 128 | } 129 | }); 130 | }) 131 | } 132 | 133 | export {axios} 134 | -------------------------------------------------------------------------------- /src/components/TextEditor/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "./index.css" 3 | import {uuid, pathJoin} from "@/utils"; 4 | 5 | // 功能键 6 | const KEYS = { 7 | META: 'metaKey', 8 | CTRL: 'ctrlKey', 9 | SHIFT: 'shiftKey', 10 | ALT: 'altKey' 11 | }; 12 | 13 | /** 14 | * 判断组合键 15 | * 16 | * @param array keys 17 | * @param string key 18 | */ 19 | const testCompKey = (keys, key) => { 20 | let fks = new Set(['metaKey', 'ctrlKey', 'shiftKey', 'altKey']); 21 | for (let fk of keys) { 22 | if(typeof fk == 'object'){ 23 | let count = 0; 24 | for(let sfk of fk){ 25 | if(event[sfk]) count++; 26 | fks.delete(sfk) 27 | } 28 | if(count != 1){ 29 | return false; 30 | } 31 | }else{ 32 | if(event[fk]){ 33 | fks.delete(fk) 34 | }else{ 35 | return false; 36 | } 37 | } 38 | } 39 | for(let fk of fks.keys()){ 40 | if(event[fk]) return false; 41 | } 42 | return event.key.toLowerCase() == key; 43 | } 44 | 45 | function TextEditor(props) { 46 | let tabSize = props.tabSize || 2; 47 | 48 | function handleKeyDown(ev) { 49 | if(ev.key == 'Tab'){ 50 | let selectionStart = ev.target.selectionStart; 51 | let start = ev.target.value.substring(0, selectionStart) 52 | let end = ev.target.value.substr(ev.target.selectionEnd) 53 | ev.target.value = start + Array(tabSize).fill(' ').join('') + end; 54 | ev.target.selectionStart = ev.target.selectionEnd = selectionStart+tabSize; 55 | ev.preventDefault(); 56 | return; 57 | } 58 | 59 | // 自定义组合键 60 | if(testCompKey([[KEYS.CTRL, KEYS.META]], 's')){ 61 | props.onKeyDown({ 62 | type: 'save', 63 | value: event.target.value 64 | }) 65 | ev.preventDefault(); 66 | return; 67 | } 68 | 69 | // Force save Meta+Shift+s | Ctrl+Shift+s 70 | if(testCompKey([[KEYS.CTRL, KEYS.META], KEYS.SHIFT], 's')){ 71 | props.onKeyDown({ 72 | type: 'forceSave', 73 | value: event.target.value 74 | }) 75 | ev.preventDefault(); 76 | return; 77 | } 78 | // Cut line 79 | if(testCompKey([[KEYS.CTRL, KEYS.META]], 'x')){ 80 | props.onKeyDown({ 81 | type: 'cutLine', 82 | value: '' 83 | }) 84 | ev.preventDefault(); 85 | return; 86 | } 87 | 88 | } 89 | 90 | function handlePast(ev) { 91 | let file = event.clipboardData.files[0]; 92 | if(!file) return; 93 | let name = uuid(8, 62) + file.name; 94 | 95 | // 上传图片并插入编辑器 96 | if(/^image\//.test(file.type)){ 97 | let url = 'assets/images' 98 | let path = pathJoin(url, name) 99 | let imgStr = `![${name}](${path})`; 100 | 101 | let selectionStart = ev.target.selectionStart; 102 | let start = ev.target.value.substring(0, selectionStart) 103 | let end = ev.target.value.substr(ev.target.selectionEnd) 104 | let newStr = `${imgStr}\n` + end; 105 | ev.target.value = start + newStr; 106 | ev.target.selectionStart = ev.target.selectionEnd = selectionStart+newStr.length; 107 | 108 | props.onImageUploadState(path, 0); 109 | props.onChange(ev.target.value); 110 | background.uploadFiles(path, file).then(res => { 111 | if(res.success){ 112 | props.onImageUploadState(path, 1); 113 | }else{ 114 | props.onImageUploadState(path, 2, res.message); 115 | } 116 | }, err => { 117 | props.onImageUploadState(path, 2, err.message); 118 | }) 119 | } 120 | event.preventDefault(); 121 | } 122 | 123 | function onChangeListener() { 124 | props.onChange(event.target.value); 125 | } 126 | const view = ( 127 |
128 | 136 |
137 | ); 138 | return view; 139 | } 140 | 141 | export default TextEditor; 142 | -------------------------------------------------------------------------------- /assets/images/chrome-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/pages/background.js: -------------------------------------------------------------------------------- 1 | import {promisify, Store} from '../utils'; 2 | import * as Api from '@/api'; 3 | 4 | !function() { 5 | const data = { 6 | name: undefined, 7 | login: undefined, 8 | avatar: undefined, 9 | token: undefined, // *required 10 | note: 'chrome extension access token', 11 | 12 | repo: undefined, // *required 13 | branch: 'master', // "master" | "gh-pages" 14 | path: '/_posts', // "/" | "/docs/_posts" | "/_posts" 15 | message: 'by chrome extension', 16 | baseUrl: undefined, 17 | 18 | postsIndex: null, // @type array 缓存文章索引 19 | 20 | editorWidthPercent: 0.5, 21 | 22 | validated: false, 23 | 24 | // TODO: 25 | // categories: ['笔记', '随笔', '前端'], 26 | // tags: ['Javascript', 'css'], 27 | }; 28 | 29 | async function check(){ 30 | // 初始化数据 31 | await initData(); 32 | let token = await validToken(); 33 | if(!token.success){ 34 | return token; 35 | } 36 | return await checkConfig(); 37 | } 38 | 39 | /** 40 | * Init data 41 | * 42 | * @returns {Promise} 43 | */ 44 | async function initData() { 45 | let apiData = new Set(['token', 'login', 'repo', 'path', 'branch', 'note', 'message']); 46 | Store.change(function (changes) { 47 | for(let key in changes){ 48 | data[key] = changes[key].newValue; 49 | if(apiData.has(key)){ 50 | Api.setData(key, data[key]); 51 | } 52 | } 53 | }); 54 | Object.assign(data, await Store.get()); 55 | apiData.forEach(key => { 56 | Api.setData(key, data[key]); 57 | }) 58 | await dev(); 59 | } 60 | 61 | async function dev(){ 62 | let info = await promisify(chrome.management.getSelf)(); 63 | if(info.installType == 'development'){ 64 | console.log(data); 65 | Store.log(); 66 | } 67 | } 68 | 69 | async function validToken(){ 70 | if(!data.token) { 71 | return {success: false, message: 'token not set'} 72 | } 73 | 74 | // 通过获取用户信息判断token是否失效 75 | let user = await Api.getUserInfo(); 76 | if(!user.success){ 77 | await Store.remove('token') 78 | return {success: false, message: user.data.message}; 79 | } 80 | return {success: true}; 81 | } 82 | 83 | async function checkConfig(){ 84 | if(!data.repo){ 85 | return {success: false, message: 'repo not set'} 86 | } 87 | 88 | // 通过访问仓库检测配置是否有效 89 | let repo = await Api.getContents(); 90 | data.validated = repo.success; 91 | return repo; 92 | } 93 | 94 | async function updateUserInfo(user){ 95 | if(user){ 96 | await Store.set({ 97 | name: user.data.name, 98 | login: user.data.login, 99 | avatar: user.data.avatar_url, 100 | }); 101 | user.success = true; 102 | }else{ 103 | user = await Api.getUserInfo(); 104 | if(user.success){ 105 | await Store.set({ 106 | name: user.data.name, 107 | login: user.data.login, 108 | avatar: user.data.avatar_url, 109 | }); 110 | } 111 | } 112 | return user; 113 | } 114 | 115 | function getContents(path, isRaw) { 116 | return Api.getContents(path, isRaw); 117 | } 118 | 119 | function createContent(path, content, sha) { 120 | return Api.updateContents(path, content, sha); 121 | } 122 | 123 | /** 124 | * 登录 125 | * 126 | * @param args 127 | * @returns {Promise} 128 | */ 129 | async function doLogin(...args){ 130 | if(args.length == 1){ 131 | await Store.set({ 132 | token: args[0] 133 | }); 134 | return await updateUserInfo(); 135 | }else{ 136 | // 查询旧的 PAT,若存在则删除 137 | let authorizations = await Api.listAuthorizations(...args); 138 | if(!authorizations.success){ 139 | return authorizations; 140 | } 141 | let auth = authorizations.data.find(data => data.note == data.note); 142 | if(auth){ 143 | let del = await Api.deleteAuthorization(...args, auth.id); 144 | if(!del){ 145 | return del; 146 | } 147 | } 148 | // 创建新的 PAT 149 | auth = await Api.createAuthorization(...args, data.note) 150 | if(!auth.success){ 151 | return auth; 152 | } 153 | await Store.set({ 154 | token: auth.data.token 155 | }); 156 | // 获取用户信息 157 | return await updateUserInfo(); 158 | } 159 | } 160 | 161 | /** 162 | * 设置目标仓库 163 | * 164 | * @param repoName 165 | * @returns {Promise} 166 | */ 167 | async function setRepo(repo, branch, path){ 168 | let valid = await Api.getBranches(repo, branch); 169 | data.validated = valid.success; 170 | if(valid.success){ await Store.set({repo, branch, path}); } 171 | return valid; 172 | } 173 | 174 | function uploadFiles(path, file) { 175 | return Api.uploadFiles(path, file) 176 | } 177 | 178 | const Background = { 179 | doLogin, 180 | setRepo, 181 | getContents, 182 | createContent, 183 | checkConfig, 184 | validToken, 185 | uploadFiles, 186 | save: Store.set, 187 | get: Store.get, 188 | remove: Store.remove, 189 | get postsIndex(){ return data.postsIndex; }, 190 | get avatar(){ return data.avatar; }, 191 | get login(){ return data.login; }, 192 | get repo(){ return data.repo; }, 193 | get branch(){ return data.branch; }, 194 | get path(){ return data.path; }, 195 | get width(){ return data.editorWidthPercent; }, 196 | get baseUrl(){ return data.baseUrl || 'https://' + data.repo; } 197 | } 198 | 199 | window.actions = Background; 200 | 201 | chrome.browserAction.onClicked.addListener(() => { 202 | console.log(data.validated) 203 | if(data.validated){ 204 | chrome.tabs.create({ 205 | url: 'editor.html' 206 | }) 207 | }else{ 208 | chrome.runtime.openOptionsPage(); 209 | } 210 | }); 211 | 212 | check().catch(err => { 213 | console.log(err); 214 | }).then(()=>{ 215 | chrome.browserAction.setPopup({popup: ''}); 216 | }) 217 | 218 | chrome.tabs.onActivated.addListener(e=> { 219 | console.log(e.id) 220 | }) 221 | }(); 222 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * promise 风格转化,仅用于 Chrome extension API 3 | * 4 | * @param {Function} fn 异步 Chrome extension API 5 | * @param {Object} area 上下文对象,针对 chrome.storage 下的 get、set 等方法 6 | * @returns {function(...[*]): Promise} 7 | */ 8 | let promisify = (fn, area) => function (...args) { 9 | return new Promise((resolve, reject) => { 10 | let callback = function (...args) { 11 | resolve(...args); 12 | } 13 | fn.apply(area || null, [...args, callback]) 14 | }); 15 | }; 16 | 17 | let Store = { 18 | set: promisify(chrome.storage.sync.set, chrome.storage.sync), 19 | get: promisify(chrome.storage.sync.get, chrome.storage.sync), 20 | remove: promisify(chrome.storage.sync.remove, chrome.storage.sync), 21 | clear: promisify(chrome.storage.sync.clear, chrome.storage.sync), 22 | change: fn => chrome.storage.sync.onChanged.addListener(fn), 23 | log: () => chrome.storage.onChanged.addListener(changes => { 24 | for (let key in changes) { 25 | let storageChange = changes[key]; 26 | console.log('Store:log: "%s" changed from "%s" to "%s".', key, storageChange.oldValue, storageChange.newValue); 27 | } 28 | }) 29 | }; 30 | 31 | /** 32 | * DateFormat(new Date(), "[ '北京时间' z yyyy-MM-dd a hh:mm:ss.SSSS EE]") 33 | * DateFormat("[ '北京时间' z yyyy-MM-dd a hh:mm:ss.SSSS EE]") 34 | * DateFormat() 35 | * 36 | * @param date 37 | * @param pattern 38 | * @returns {string} 39 | * @constructor 40 | */ 41 | function DateFormat(date, pattern) { 42 | if(arguments.length == 1 && typeof date == 'string'){ 43 | pattern = date; 44 | date = null 45 | } 46 | var _p = pattern || 'yyyy/MM/dd HH:mm:ss' 47 | date = date||new Date() 48 | 49 | var _PLACEGOLDER = '_@@_' 50 | 51 | var AP = ['AM', 'PM'] 52 | var WEEKDAY = ['日','一','二','三','四','五','六', '礼拜'] 53 | 54 | var textIgnore = [] 55 | var _i = 0 56 | 57 | _p = _p.replace(/('.*')/g, function(match){ 58 | textIgnore.push(match.substring(1, match.length-1) ) 59 | return _PLACEGOLDER 60 | }) 61 | 62 | var lengthHandler= function(str, length){ 63 | str += '' 64 | var l = length - str.length 65 | if(l > 0) { 66 | return (Math.pow(10, l)+'').substr(1) + str 67 | }else { 68 | return str.substr(0-l, length) 69 | } 70 | } 71 | 72 | var patterns = {} 73 | //Year 74 | patterns['y+'] = function (match, offset, string){ 75 | let target = date.getFullYear() 76 | return lengthHandler(target, match.length) 77 | } 78 | //Month in year 79 | patterns['M+'] = function (match, offset, string){ 80 | let target = date.getMonth() + 1 81 | return lengthHandler(target, match.length) 82 | } 83 | //Day in month 84 | patterns['d+'] = function (match, offset, string){ 85 | let target = date.getDate() 86 | return lengthHandler(target, match.length) 87 | } 88 | //Am/pm marker 89 | patterns['a'] = function (match, offset, string){ 90 | let target = date.getHours() 91 | if(target >= 0 && target < 12){ 92 | target = AP[0] 93 | }else{ 94 | target = AP[1] 95 | } 96 | return target 97 | } 98 | 99 | //Hour in day (0-23) 100 | patterns['H+'] = function (match, offset, string){ 101 | let target = date.getHours() 102 | return lengthHandler(target, match.length) 103 | } 104 | 105 | //Hour in am/pm (1-12) 106 | patterns['h+'] = function (match, offset, string){ 107 | let target = date.getHours() 108 | target = (target > 12 || target == 0) ? Math.abs(target - 12) : target 109 | return lengthHandler(target, match.length) 110 | } 111 | 112 | //Minute in hour(0-59) 113 | patterns['m+'] = function (match, offset, string){ 114 | let target = date.getMinutes() 115 | return lengthHandler(target, match.length) 116 | } 117 | 118 | //Seconds in minute (0-59) 119 | patterns['s+'] = function (match, offset, string){ 120 | let target = date.getSeconds() 121 | return lengthHandler(target, match.length) 122 | } 123 | 124 | //Millisecond 125 | patterns['S+'] = function (match, offset, string){ 126 | let target = date.getSeconds() 127 | return lengthHandler(target, match.length) 128 | } 129 | 130 | //Day name in week 131 | patterns['E+'] = function (match, offset, string){ 132 | let target = date.getDay() 133 | let length = match.length 134 | if(length == 1){ 135 | return WEEKDAY[target] 136 | }else if(length == 2){ 137 | return (WEEKDAY[7]||'周') + WEEKDAY[target] 138 | } 139 | 140 | } 141 | 142 | //Time zone 143 | patterns['z+'] = function (match, offset, string){ 144 | let target = date.getTimezoneOffset() 145 | if(target > 0){ 146 | return '-' + (target/60) 147 | } 148 | return '+' + (-target/60) 149 | } 150 | 151 | for(var k in patterns) { 152 | _p = _p.replace(new RegExp(k, 'g'), patterns[k]); 153 | } 154 | 155 | _p = _p.replace(new RegExp(_PLACEGOLDER, 'g'), function(){ 156 | return textIgnore[_i++] 157 | }) 158 | 159 | return _p 160 | } 161 | 162 | function pathJoin(...paths) { 163 | return paths.map(path => path.replace(/(^\/)|(\/$)/g, '')) 164 | .filter(i => i!='').join('/') 165 | } 166 | 167 | /** 168 | * 生成指定位数&基数的UUID 169 | * @param len 170 | * @param radix (2-62) 171 | * @returns {string} 172 | */ 173 | function uuid(len, radix) { 174 | let chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'.split(''); 175 | let uuid = [], i; 176 | radix = radix || chars.length; 177 | 178 | if (len) { 179 | for (i = 0; i < len; i++) 180 | uuid[i] = chars[0 | Math.random()*radix]; 181 | } else { 182 | // rfc4122, version 4 form 183 | var r; 184 | 185 | // rfc4122 requires these characters 186 | uuid[8] = uuid[13] = uuid[18] = uuid[23] = '-'; 187 | uuid[14] = '4'; 188 | 189 | // Fill in random data. At i==19 set the high bits of clock sequence as 190 | // per rfc4122, sec. 4.1.5 191 | for (i = 0; i < 36; i++) { 192 | if (!uuid[i]) { 193 | r = 0 | Math.random()*16; 194 | uuid[i] = chars[(i == 19) ? (r & 0x3) | 0x8 : r]; 195 | } 196 | } 197 | } 198 | return uuid.join(''); 199 | } 200 | 201 | 202 | export { 203 | promisify, Store, DateFormat, pathJoin, uuid 204 | } 205 | -------------------------------------------------------------------------------- /src/views/option/auth.jsx: -------------------------------------------------------------------------------- 1 | import React, {Fragment} from 'react'; 2 | import Button from '@material-ui/core/Button'; 3 | import Typography from '@material-ui/core/Typography'; 4 | import TextField from '@material-ui/core/TextField'; 5 | import IconButton from '@material-ui/core/IconButton'; 6 | import Input from '@material-ui/core/Input'; 7 | import InputLabel from '@material-ui/core/InputLabel'; 8 | import InputAdornment from '@material-ui/core/InputAdornment'; 9 | import FormHelperText from '@material-ui/core/FormHelperText'; 10 | import FormControl from '@material-ui/core/FormControl'; 11 | import Visibility from '@material-ui/icons/Visibility'; 12 | import VisibilityOff from '@material-ui/icons/VisibilityOff'; 13 | import { withStyles } from '@material-ui/core/styles'; 14 | import DialogTitle from "@material-ui/core/DialogTitle"; 15 | import DialogContent from "@material-ui/core/DialogContent"; 16 | import Files from "@/views/editor/files"; 17 | import DialogActions from "@material-ui/core/DialogActions"; 18 | import Dialog from "@material-ui/core/Dialog"; 19 | import Link from '@material-ui/core/Link'; 20 | 21 | const loginType = { 22 | PASSWORD: 'password', 23 | TOKEN: 'token' 24 | } 25 | 26 | class Auth extends React.Component { 27 | constructor(props) { 28 | super(props); 29 | this.state = { 30 | loginType: loginType.PASSWORD, 31 | username: 'chengqifw@gmail.com', 32 | password: 'cbd579923.github', 33 | token: '', 34 | errors: {}, 35 | showPassword: false 36 | } 37 | this.background = chrome.extension.getBackgroundPage().actions; 38 | } 39 | 40 | changeType(loginType){ 41 | this.setState({loginType}) 42 | } 43 | 44 | handleChange(field){ 45 | this.setState({[field]: event.target.value}) 46 | } 47 | 48 | handleClickShowPassword(){ 49 | let showPassword = !this.state.showPassword; 50 | this.setState({showPassword}) 51 | } 52 | 53 | validate(params){ 54 | for(let key of params){ 55 | let val = this.state[key]; 56 | if(!val){ 57 | this.state.errors[key] = true; 58 | this.setState({errors: this.state.errors}) 59 | return false; 60 | }else if(this.state.errors[key] == true){ 61 | this.state.errors[key] = false; 62 | this.setState({errors: this.state.errors}) 63 | } 64 | } 65 | return true; 66 | } 67 | 68 | submit(){ 69 | let args = this.state.loginType == loginType.PASSWORD ? ['username', 'password'] : ['token']; 70 | if(this.validate(args)){ 71 | this.props.onSubmit(new Promise((resolve, reject) => { 72 | let params = args.map(key => this.state[key]); 73 | this.background.doLogin(...params).then(res => { 74 | if(res.success){ 75 | resolve(); 76 | }else{ 77 | reject(err.message); 78 | } 79 | }).catch(err => { 80 | reject(err.message); 81 | }); 82 | })); 83 | }else{ 84 | this.props.onSubmit(Promise.reject()); 85 | } 86 | } 87 | 88 | genFieldsPWD(classes){ 89 | return
90 | 91 | 提供你的 GitHub 账号密码进行授权 92 | 93 | 103 | 104 | {this.state.password.error?this.state.password.info:'Password'} 105 | 113 | 117 | {this.state.showPassword ? : } 118 | 119 | 120 | } 121 | /> 122 | 密码只用于获取 GitHub token,不会被存储到任何地方。 123 | 124 | 使用 token 125 |
; 126 | } 127 | 128 | genFieldsToken(classes){ 129 | return
130 | 131 | 提供你的 GitHub Private access token 132 | 133 | 143 | 密码登录 144 |
; 145 | } 146 | 147 | render() { 148 | // 在导出时使用 withStyles 注入的 149 | const { classes } = this.props; 150 | return 151 | {this.state.loginType == loginType.PASSWORD ? this.genFieldsPWD(classes) : this.genFieldsToken(classes)} 152 |
153 | 154 |
155 |
156 | } 157 | } 158 | const styles = theme => ({ 159 | fieldset: { 160 | border: 'none', margin: 0, padding: 0 161 | }, 162 | field: {marginBottom: theme.spacing.unit}, 163 | linkBtn: {cursor: 'pointer'}, 164 | actions: {textAlign: 'right', marginTop: 8} 165 | }); 166 | 167 | export default withStyles(styles)(Auth); 168 | -------------------------------------------------------------------------------- /src/views/option/form.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Button from '@material-ui/core/Button'; 3 | import Typography from '@material-ui/core/Typography'; 4 | import TextField from '@material-ui/core/TextField'; 5 | import IconButton from '@material-ui/core/IconButton'; 6 | import Input from '@material-ui/core/Input'; 7 | import InputLabel from '@material-ui/core/InputLabel'; 8 | import InputAdornment from '@material-ui/core/InputAdornment'; 9 | import FormHelperText from '@material-ui/core/FormHelperText'; 10 | import FormControl from '@material-ui/core/FormControl'; 11 | import Visibility from '@material-ui/icons/Visibility'; 12 | import VisibilityOff from '@material-ui/icons/VisibilityOff'; 13 | import { withStyles } from '@material-ui/core/styles'; 14 | import Paper from '@material-ui/core/Paper'; 15 | 16 | class Validate { 17 | constructor(props, config){ 18 | this.fields = props; 19 | this.conf = Object.assign({ 20 | passedKeys: false, 21 | breakFailed: true 22 | },config) 23 | } 24 | valid(){ 25 | let keys, data; 26 | if(arguments.length == 0){ 27 | throw new TypeError('missing parameter'); 28 | }else if(arguments.length == 1){ 29 | keys = Object.keys(this.fields) 30 | data = arguments[0] 31 | }else{ 32 | keys = arguments[0] 33 | data = arguments[1] 34 | } 35 | 36 | let results = {}; 37 | let size = 0; 38 | for(let key of keys){ 39 | for(let checker of this.fields[key]){ 40 | let method = checker[0]; 41 | if(typeof method == 'string'){ 42 | method = this[method]; 43 | } 44 | let pass = method(data[key]); 45 | if(pass){ 46 | if(this.conf.passedKeys) results[key] = undefined; 47 | }else{ 48 | results[key] = checker[1]; 49 | size++; 50 | break; 51 | } 52 | } 53 | } 54 | Object.defineProperty(results, 'size', { 55 | value: size, 56 | 57 | }) 58 | this.handler(results) 59 | return results.size == 0; 60 | } 61 | setHandler(cb){ 62 | this.handler = cb 63 | return this; 64 | } 65 | require(data){ 66 | return data != null && data !== ''; 67 | } 68 | } 69 | 70 | class Config extends React.Component { 71 | constructor(props) { 72 | super(props); 73 | this.state = { 74 | repo: background.repo || background.login + '.github.io', 75 | branch: background.branch, 76 | path: background.path, 77 | error: {}, 78 | } 79 | Validate.prototype.branch = (data) => /^\w+$/.test(data); 80 | Validate.prototype.path = (data) => /^\/(\w+\/?)+$/.test(data); 81 | this.validate = new Validate({ 82 | repo: [ 83 | ['require','请填写 repo 字段'], 84 | ], 85 | branch: [ 86 | ['require','请填写 branch 字段'], 87 | ['branch', '请填写合法分支名称'] 88 | ], 89 | path: [ 90 | ['require','请填写 path 字段'], 91 | ['path', '请填写合法路径'] 92 | ] 93 | }).setHandler(error => { 94 | this.setState({error}) 95 | }) 96 | } 97 | 98 | handleChange(field){ 99 | this.setState({[field]: event.target.value}) 100 | } 101 | handleFocus(field){ 102 | if(this.state.error[field]){ 103 | delete this.state.error[field]; 104 | this.setState({error: this.state.error}); 105 | } 106 | } 107 | 108 | submit(){ 109 | let args = ['repo', 'branch', 'path']; 110 | console.log('before submit', this.state) 111 | if(this.validate.valid(this.state)){ 112 | this.props.onSubmit(background.setRepo(this.state.repo, this.state.branch, this.state.path)); 113 | } 114 | } 115 | 116 | 117 | componentDidUpdate(preProps, preState, snapShot) { 118 | if(preProps.submit != this.props.submit && this.props.submit) 119 | this.submit(); 120 | } 121 | 122 | render() { 123 | // 在导出时使用 withStyles 注入的 124 | const { classes } = this.props; 125 | 126 | return ( 127 | 128 | 129 | GitHub page 参数设置 130 | 131 |
132 | 144 |
145 |
146 | 158 |
159 |
160 | 172 |
173 |
174 | 175 |
176 |
177 | ); 178 | } 179 | } 180 | const styles = theme => ({ 181 | root: { 182 | padding: '0 24px 24px' 183 | }, 184 | fieldset: { 185 | border: 'none', margin: 0, padding: 0 186 | }, 187 | field: {marginBottom: theme.spacing.unit}, 188 | linkBtn: {float: 'right'}, 189 | actions: {textAlign: 'right', marginTop: 8} 190 | }); 191 | 192 | export default withStyles(styles)(Config); 193 | -------------------------------------------------------------------------------- /note.md: -------------------------------------------------------------------------------- 1 | # webpack 配置 2 | ## 目标: 3 | 1. 源码是按模块分包(html、js、css 等在同一个目录),希望打包后抽出js、css、图片等资源到资源目录。 4 | 2. 使用Sass/Less 5 | 3. 使用 react 构建用户界面 6 | 4. 希望引入 eslint 7 | 5. 要能提取大文件 8 | 6. 有一个开发服务器跑着 9 | 7. 能打出可直接加载的开发包 10 | 8. 能打出可直接发布的生产包 11 | 12 | OK 就是这样。 13 | 14 | 15 | 16 | ## 配置指南 17 | ### 1. 配置入口(entry) 18 | 19 | #### context 20 | 使用 `context` 字段指定入口文件所处的目录的绝对路径。换句话说就是指定一个绝对路径作为基础目录,后面解析入口、loaders 都是相对这个目录进行的。 21 | 22 | 举个栗子: 23 | 24 | project 25 | ``` 26 | webpack-demo 27 | |-src 28 | |-index.jsx 29 | ``` 30 | config 31 | ```js 32 | // __dirname: /Users/Frank/webpack-demo/ 33 | context: __dirname, 34 | entry: { 35 | index: './src/index.jsx' 36 | } 37 | ``` 38 | 上面是webpack的默认配置,意思是在当前目录查找 './src/index.jsx'。 39 | 40 | 下面尝试改一下 context 41 | ```js 42 | context: '/Users/Frank/webpack-demo/src', 43 | entry: { 44 | index: './src/index.jsx' 45 | } 46 | ``` 47 | 很显然这次 webpack 不会构建成功,错误信息是:`ERROR in Entry module not found: Error: Can't resolve './src/view.js' in '/Users/ylfe/Frank/webpack-demo/src'` 48 | 49 | 不难看出,webpack 是到 context 指定的路径查找 entry。 50 | 51 | #### entry 52 | 在web项目中,入口通常是在 html 中通过 script 标签引入的 js 脚本。 53 | 一般一个html只引入一个 js 脚本,其他资源都在这个js脚本里面通过import、require等引用,这可以充分发挥 webpack 的依赖分析、自动转换、打包等功能的优势。 54 | 55 | `entry` 的值可以是一个字符串、数组、对象或是函数。 56 | #### >字符串 57 | ```js 58 | entry: 'index.jsx' 59 | // 等价于 60 | entry: { 61 | main: 'index.jsx' 62 | } 63 | ``` 64 | `chunk` 被命名为 `main` 65 | 66 | 看一下打包结果: 67 | ```js 68 | ...webpackBootstrap 69 | 70 | ({/***/ "./index.jsx": 71 | /*!******************!*\ 72 | !*** ./index.jsx ***! 73 | \******************/ 74 | /*! no static exports found */ 75 | /***/ (function(module, exports) { 76 | eval("\n\n//# sourceURL=webpack:///./index.jsx?"); 77 | /***/ }) 78 | /******/ }); 79 | ``` 80 | #### >数组 81 | ```js 82 | entry: ['index.jsx'] 83 | 84 | ``` 85 | webpack 针对数组中的每一项进行依赖分析、打包,只生成一个 bundle 文件。 86 | `chunk` 被命名为 `main` 87 | 88 | 虽然只有一项,bundle内容也不同于上例: 89 | ```js 90 | ({ 91 | /***/ "./index.jsx": 92 | /*!******************!*\ 93 | !*** ./index.jsx ***! 94 | \******************/ 95 | /*! no static exports found */ 96 | /***/ (function(module, exports) { 97 | eval("\n\n//# sourceURL=webpack:///./index.jsx?"); 98 | /***/ }), 99 | /***/ 0: 100 | /*!************************!*\ 101 | !*** multi ./index.jsx ***! 102 | \************************/ 103 | /*! no static exports found */ 104 | /***/ (function(module, exports, __webpack_require__) { 105 | eval("module.exports = __webpack_require__(/*! ./index.jsx */\"./index.jsx\");\n\n\n//# sourceURL=webpack:///multi_./index.jsx?"); 106 | /***/ }) 107 | /******/ }); 108 | ``` 109 | #### >对象 110 | ```js 111 | entry: { 112 | index: 'index.jsx' 113 | }, 114 | ``` 115 | 每个键(key)会是 chunk 的名称,值描述了 chunk 的入口文件(在output中通过[name]取得) 116 | 117 | #### >函数 118 | ```js 119 | entry: () => new Promise(resolve => { 120 | setTimeout(()=>{ 121 | resolve({ 122 | index: './index.jsx' 123 | }) 124 | }) 125 | }) 126 | ``` 127 | 通过promise设置 字符串、数组、对象等。 128 | 129 | ### 2. 配置输出(output) 130 | 顶层的 `output` 字段用一系列的配置项来告诉 webpack 将它处理的资源输出到哪里。 131 | 因为 webpack4 把分包的活也揽了过来,所以这个配置就比较复杂了。 132 | 133 | #### filename 134 | ```js 135 | filename: '[name].js' // 默认值,name 是 chunk 的名称,默认为 main 136 | ``` 137 | 这个选项指定输出 bundle 的名称,对于单个 入口项目, filename可以使用占位符也可以使用静态名称, 138 | 对于多入口项目则必须使用占位符以生成不同名称的 bundle。否则可以看到这样的错误信息: 139 | ``` 140 | ERROR in chunk view [entry] 141 | ./js/bundle.js 142 | Conflict: Multiple chunks emit assets to the same filename ./js/bundle.js (chunks index and view) 143 | ``` 144 | 虽然名字叫 filename ,其实也可以指定路径的(相对于path字段设置的路径)。如:`js/[chunkhash].js` 145 | 146 | ps: 常用占位符有 147 | * [name] 模块名称 148 | * [id] 模块标识符 149 | * [hash] 模块标识符的 hash 150 | * [chunkhash] chunk 内容的 hash 151 | * [contenthash] 使用`ExtractTextWebpackPlugin`提取出的资源的 hash 152 | 153 | 可以指定 hash 长度: [hash:16]、[chunkhash:10]、[contenthash:10] (默认20) 154 | 也可以通过字段 'output.hashDigestLength' 全局设置。 155 | 156 | ### module 157 | 指定如何处理项目中不同的模块 158 | 159 | #### noParse: 正则、正则数组、函数、字符串 160 | 指定不处理哪些模块。举个栗子: 161 | ```js 162 | module: { 163 | noParse: /jquery/ 164 | } 165 | ``` 166 | 其它写法: 167 | ```js 168 | noParse: [/jquery/] 169 | noParse: 'jquery' 170 | noParse: content => /jquery/.test(content) 171 | ``` 172 | 这意味着即使在 `.src/index.jsx` 里面 'import' 了 jquery ,webpack 也不会把 jquery 打进 bundle,其实根本就没有去处理 jquery。 173 | > 既然忽略了 jquery,就不要再使用 import 等引用它了。测试发现如果 import 的目标模块被 noParse 匹配,webpack 不会转译这个文件的所有 import 语句。。。 174 | 175 | 忽略一些比较大的没采用模块化的库可以大大提升编译速度。 176 | 177 | --- 178 | 179 | #### rules 规则数组 180 | 这些规则能够修改模块的创建方式,如指定加载相关模块时应用的loader、修改解析器等。 181 | 182 | 一个规则包括三个部分:条件、结果、嵌套规则。 183 | * 条件:用于判断是否对资源使用此规则 184 | * 结果:输出要对资源应用的 loaders、解析选项。 185 | * 嵌套规则:嵌套属性写在 `rules` 和 `onOf` 下 186 | 187 | 资源有两个相关值供执行条件匹配测试: 188 | 189 | 1. resource: 被请求资源的模块文件的绝对路径。 190 | 2. issuer: 请求当前资源的模块(`import` 语句所在模块文件)的绝对路径。 191 | 192 | * 条件设置字段(匹配目标是上文的 `resource` ) 193 | * test: 正则表达式|正则表达式数组,匹配特定条件 194 | * include: 字符串|字符串数组,匹配特定条件 195 | * exclude: 字符串|字符串数组,排除特定条件 196 | * and: 条件数组,必须匹配数组中的所有条件 197 | * or: 条件数组,匹配数组中任何一个条件 198 | * not: 条件数组,必须不匹配数组中的所有条件 199 | > 条件可以是这些之一: 200 | >* 字符串:输入必须以提供的字符串开始 201 | >* 正则表达式: test用 202 | >* 函数: 返回一个真值 203 | >* 条件数组: 至少一个条件匹配 204 | >* 对象: 每个属性定义的条件都要匹配 205 | 206 | * `Rule.issuer`(匹配目标是上文的 `resource` ): 类似 'Rule.test',只不过issuer的匹配对象是issuer 207 | * `Rule.test` : `Rule.resource.test` 的简写,提供此选项就不能再提供 `Rule.resource` 208 | * `Rule.exclude` : `Rule.resource.exclude` 的简写,提供此选项就不能再提供 `Rule.resource` 209 | * `Rule.include` : `Rule.resource.include` 的简写,提供此选项就不能再提供 `Rule.resource` 210 | 211 | * `Rule.use` : 字符串、字符串数组、对象数组、函数 212 | 字符串/数组 213 | ```js 214 | module: { 215 | rules: [ 216 | //这俩是 use:[{loader: 'css-loader'}] 的简写形式 217 | {test: /\.css$/, use: ['css-loader']} 218 | {test: /\.css$/, use: 'css-loader'} 219 | ] 220 | } 221 | ``` 222 | 对象数组 223 | ```js 224 | module: { 225 | rules: [ 226 | { 227 | test: /\.css$/, 228 | use: { 229 | loader: String, // 必须 230 | options: {}, // 可选,传递给 loader 的参数 231 | query: {} // 废弃,使用 `options` 替代 232 | } 233 | } 234 | ] 235 | } 236 | ``` 237 | 函数写法 238 | ```js 239 | /** 240 | info: { 241 | compiler: 当前webpack的compiler, 242 | issuer: 发起当前引用的模块的路径, 243 | realResource: 当前正在加载的模块的路径 244 | resource: 一般和 realResource 一致,除非在引用语句中使用 `!=!` 指定了资源名称? 245 | } 246 | */ 247 | use: info => [ 248 | {loader: 'style-loader'}, {loader: 'css-loader'} 249 | ] 250 | ``` 251 | 252 | \# `options.ident` 253 | 254 | webpack 根据模块的资源、loaders 和 loaders 的 配置项(options)来为模块生成一个全局唯一的**模块标识符**。webpack并不能保证这个标识符百分百全局唯一,所以你也可以使用 `options.ident` 字段指定一个唯一标识符。 255 | > options 对象是经过 `JSON.stringifi()` 字符化后参与生成标识符的。所以存在这种情况,你将两个相同的 loader 应用了不同的配置(options),然后将这两个loader应用于某个资源。此时如果两个 options 经过 `JSON.stringifi()` 生成的字符串是相同的,那么 webpack 为此模块生成的 标识符(module identifier)就不能保证唯一了。 256 | 257 | * `Rule.rules` : 嵌套规则数组,在当前规则匹配时使用 258 | * `Rule.onOf` : 嵌套规则数组,在当前规则匹配时使用, 但是只应用匹配到的第一个规则 259 | * `Rule.resource` : 配置匹配条件,一般都用上述的简单方法 260 | * `Rule.resourceQuery` : 当前规则条件匹配成功是再进一步匹配参数。🌰 261 | ```js 262 | import Foo from './foo.css?inline' 263 | // 匹配成功,使用url-loader 处理 foo.css 264 | rules: [ 265 | { 266 | test: /.css$/, 267 | resourceQuery: /inline/, 268 | use: 'url-loader' 269 | } 270 | ] 271 | ``` 272 | * `Rule.type` : 'javascript/auto' | 'javascript/dynamic' | 'javascript/esm' | 'json' | 'webassembly/experimental' 273 | 设置匹配到的模块的类型,能够阻止 webpack 默认的规则和加载行为。比如json文件的加载,如果我们需要使用自定义的 json 加载器,需要如下设置: 274 | ```js 275 | { 276 | test: /.json$/, 277 | type: 'javascript/auto', 278 | loader: 'custom-json-loader' 279 | } 280 | ``` 281 | * `Rule.loader` : `Rule.use: [{ loader }]` 的简写, 字符串、字符串数组 282 | * `Rule.loaders` : `Rule.use` 的别名,已废弃 283 | * `Rule.parse` : 配置解析器 284 | ```js 285 | parser: { 286 | amd: false, // disable AMD 287 | commonjs: false, // disable CommonJS 288 | system: false, // disable SystemJS 289 | harmony: false, // disable ES2015 Harmony import/export 290 | requireInclude: false, // disable require.include 291 | requireEnsure: false, // disable require.ensure 292 | requireContext: false, // disable require.context 293 | browserify: false, // disable special handling of Browserify bundles 294 | requireJs: false, // disable requirejs.* 295 | node: false, // disable dirname, filename, module, require.extensions, require.main, etc. 296 | node: {...} // reconfigure node layer on module level 297 | } 298 | ``` 299 | * `Rule.enforce` : 指定 loader 的种类。 "pre" | "post" ,没有值代表 normal loader。 300 | ```js 301 | // 内联 loaders 和 ! 前缀都是不推荐使用的。 302 | // 禁用 rules 中配置的 normal loaders 303 | import { a } from '!./file1.js'; 304 | 305 | // 禁用 rules 中配置的 normal loaders 和 preloaders 306 | import { b } from '-!./file2.js'; 307 | 308 | // 禁用 rules 中配置的所有 loaders 309 | import { c } from '!!./file3.js'; 310 | ``` 311 | 312 | 还有一种loader:内联loader:`import Styles from 'style-loader!css-loader?modules!./styles.css';` 313 | > 使用`!`将资源中的 loader 分开;使用`!`为整个规则添加前缀可以覆盖配置中的所有loader定义。 314 | 315 | ### sourcemap 316 | ```js 317 | devtool: 'inline-source-map' 318 | ``` 319 | ### 文件监听 320 | ```js 321 | npx webpack --watch 322 | // 或加入npm script 323 | { 324 | "watch": "webpack --watch" 325 | } 326 | ``` 327 | 328 | ### webpack-dev-server 329 | 安装 330 | ``` 331 | npm install -D webpack-dev-server 332 | ``` 333 | 配置 334 | ```js 335 | devServer: { 336 | contentBase: './dist' 337 | } 338 | ``` 339 | 启动脚本 340 | ```json 341 | "start": "webpack-dev-server --open" 342 | ``` 343 | > devServer 编译结束后会将 bundle 保持在内存中,不会写到任何输出文件。 344 | > 更改任何源文件并保存,devServer会自动编译后重新加载。 345 | > ps. 如果 contentBase 目录下没有index.html,打开浏览器会看到目录结构。经过观察发现这些都是本地文件,不是内存中的数据。内存中的 bundle 目录是访问不了的。 346 | -------------------------------------------------------------------------------- /src/views/editor/files.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { withStyles } from '@material-ui/core/styles'; 3 | import Button from "@material-ui/core/Button"; 4 | import TextField from "@material-ui/core/TextField"; 5 | import LinearProgress from '@material-ui/core/LinearProgress'; 6 | 7 | import SnackbarContent from '@material-ui/core/SnackbarContent'; 8 | import ErrorIcon from '@material-ui/icons/Error'; 9 | import Refresh from '@material-ui/icons/Refresh'; 10 | import IconButton from "@material-ui/core/IconButton"; 11 | import DialogTitle from "@material-ui/core/DialogTitle"; 12 | import DialogContent from "@material-ui/core/DialogContent"; 13 | import DialogActions from "@material-ui/core/DialogActions"; 14 | import Dialog from "@material-ui/core/Dialog"; 15 | import {DateFormat, pathJoin} from "@/utils.js"; 16 | 17 | class Files extends React.Component { 18 | constructor(props){ 19 | super(props); 20 | let root = background.repo; 21 | let paths = props.path.split('/') 22 | paths.pop(); 23 | this.state = { 24 | root, 25 | path: paths.join('/'), 26 | newFileName: DateFormat('yyyy-MM-dd-'), 27 | netErr: '', 28 | paths: paths 29 | }; 30 | this.loading = false; 31 | this.getContents = (path, isRaw) => { 32 | this.loading = true; 33 | return background.getContents(path, isRaw).then(res => { 34 | this.loading = false; 35 | if(!path){ 36 | this.setState({ 37 | [this.state.root]: res.data, 38 | netErr: '' 39 | }) 40 | }else{ 41 | this.setState({ 42 | [path]: res.data, 43 | netErr: '' 44 | }) 45 | } 46 | }).catch(e => { 47 | this.loading = false; 48 | this.setState({ 49 | netErr: e.message 50 | }) 51 | }) 52 | }; 53 | } 54 | 55 | handleChange(){ 56 | this.setState({newFileName: event.target.value}) 57 | } 58 | 59 | onBtnCreateClickListener(){ 60 | let {newFileName, path} = this.state; 61 | if(newFileName){ 62 | if(!/^\d{4}-\d{2}-\d{2}/.test(newFileName) && path.substr(-6) == '_posts'){ 63 | this.props.toast('文件名称必须是 yyyy-MM-dd-name 的格式', 'warning') 64 | return; 65 | } 66 | }else{ 67 | newFileName = DateFormat("yyyy-MM-dd-HHmmss") 68 | } 69 | console.log(path, newFileName, pathJoin(path, newFileName)) 70 | this.props.onCreate(pathJoin(path, newFileName+'.md')) 71 | } 72 | onFileSelectedListener(node){ 73 | if(node && node.type == 'file'){ 74 | if(node.name.substr(-2).toLowerCase() != 'md'){ 75 | this.props.toast('我只编辑 Markdown 文档!', 'info') 76 | return; 77 | } 78 | this.props.onSelect(node) 79 | }else{ 80 | this.setState({ 81 | path: node.path, 82 | paths: node.path.split('/') 83 | }) 84 | } 85 | } 86 | onLinkClickListener(index){ 87 | let paths = this.state.paths.slice(0, index+1); 88 | if(!this.state.loading){ 89 | this.setState({ 90 | path: paths.join('/'), 91 | paths 92 | }) 93 | } 94 | } 95 | onBtnCancelClickListener(){ 96 | this.props.onCancel(); 97 | } 98 | onBtnRefreshClickListener(){ 99 | this.setState({ 100 | netErr: '' 101 | }) 102 | } 103 | 104 | render() { 105 | const { classes, path } = this.props; 106 | let iconDirectory = ; 107 | let iconFile = ; 108 | 109 | const genPath = () => this.state.paths.map((path, index, {length}) => ( 110 | 115 | {path}/ 116 | 117 | )); 118 | 119 | const genNodes = () => { 120 | if(this.state.netErr){ 121 | return
122 | 126 | 127 | {this.state.netErr} 128 | 129 | } 130 | action={[ 131 | 138 | 139 | , 140 | ]} 141 | /> 142 |
143 | 144 | } 145 | let path = this.state.path; 146 | let cache = this.state[path || this.state.root]; 147 | if(cache){ 148 | return cache.map(node => ( 149 |
  • 153 | {node.type == 'file' ? iconFile : iconDirectory} 154 | {node.name} 158 |
  • 159 | )) 160 | }else{ 161 | if(!this.loading){ 162 | this.getContents(path) 163 | } 164 | return 165 | } 166 | } 167 | 168 | return ( 169 | 174 | Select post 175 | 176 |
    177 |
    178 | {this.state.root}/ 182 | {genPath()} 183 | 190 | .md 191 | 197 |
    198 |
      199 | {genNodes()} 200 |
    201 |
    202 |
    203 | 204 | 207 | 208 |
    209 | ); 210 | } 211 | } 212 | 213 | const styles = theme => ({ 214 | root: { 215 | minHeight: '40vh' 216 | }, 217 | btnNew: { float: 'right', marginRight: 8}, 218 | icon: { 219 | fill: 'rgba(3,47,98,.5)' 220 | }, 221 | directories: { 222 | fontSize: '1.2em', 223 | listStyle: 'none', 224 | paddingBlockStart: '0', 225 | paddingInlineStart: '0', 226 | '&>li': { 227 | padding: 4 228 | } 229 | }, 230 | fileName: { 231 | marginLeft: '8px', 232 | color: '#0366d6', 233 | cursor: 'pointer', 234 | '&:hover': { 235 | textDecoration: 'underline' 236 | } 237 | }, 238 | path: { 239 | lineHeight: '32px', 240 | color: '#0366d6', 241 | cursor: 'pointer', 242 | '&:hover': { 243 | textDecoration: 'underline' 244 | } 245 | }, 246 | errWrap: { 247 | display: 'flex', 248 | justifyContent: 'center', 249 | paddingTop: '160px' 250 | }, 251 | error: { 252 | backgroundColor: theme.palette.error.dark, 253 | position: 'relative', 254 | margin: '0 auto' 255 | 256 | }, 257 | message: { 258 | display: 'flex', 259 | alignItems: 'center', 260 | }, 261 | iconErr: { 262 | fontSize: 20, 263 | opacity: 0.9, 264 | marginRight: theme.spacing.unit, 265 | }, 266 | inputName: { 267 | width: '180px', 268 | marginLeft: '4px' 269 | } 270 | }); 271 | 272 | export default withStyles(styles)(Files); 273 | -------------------------------------------------------------------------------- /src/views/editor/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {withStyles} from '@material-ui/core/styles'; 3 | import EditorBar from './editor-bar'; 4 | import Files from './files'; 5 | import TextEditor from "@/components/TextEditor/index"; 6 | import Marked from "marked"; 7 | import CssBaseline from '@material-ui/core/CssBaseline'; 8 | import {DateFormat, pathJoin} from "@/utils"; 9 | import CircularProgress from "@material-ui/core/CircularProgress"; 10 | import {withSnackbar} from 'notistack'; 11 | import Typography from "@material-ui/core/Typography"; 12 | import Divider from "@material-ui/core/Divider"; 13 | import List from "@material-ui/core/List"; 14 | import ListItem from "@material-ui/core/ListItem"; 15 | import ListItemIcon from "@material-ui/core/ListItemIcon"; 16 | import ListItemText from "@material-ui/core/ListItemText"; 17 | import Drawer from "@material-ui/core/Drawer"; 18 | import './style.css' 19 | import Button from "@material-ui/core/Button"; 20 | import Dialog from "@material-ui/core/Dialog"; 21 | import DialogTitle from "@material-ui/core/DialogTitle"; 22 | import DialogContent from "@material-ui/core/DialogContent"; 23 | import DialogContentText from "@material-ui/core/DialogContentText"; 24 | import DialogActions from "@material-ui/core/DialogActions"; 25 | import Delete from '@material-ui/icons/Delete'; 26 | 27 | function setListener() { 28 | let handleMouseMove = ev => { 29 | this.setState({editorWidth: ev.pageX}); 30 | } 31 | 32 | window.onresize = ev => { 33 | if(this.state.editorWidthPercent){ 34 | this.setState({ 35 | editorWidth: document.body.offsetWidth * this.state.editorWidthPercent 36 | }); 37 | } 38 | } 39 | 40 | document.body.addEventListener('mousedown', ev => { 41 | if(ev.target.id === 'resizable'){ 42 | document.body.addEventListener('mousemove', handleMouseMove); 43 | } 44 | }) 45 | 46 | document.body.addEventListener('mouseup', ev => { 47 | if(ev.target.id === 'resizable'){ 48 | document.body.removeEventListener('mousemove', handleMouseMove); 49 | let percent = ev.pageX/document.body.offsetWidth; 50 | this.setState({ 51 | editorWidth: ev.pageX, 52 | editorWidthPercent: percent 53 | }); 54 | background.save({ 55 | editorWidthPercent: percent 56 | }) 57 | } 58 | }) 59 | } 60 | 61 | class MDEditor extends React.Component { 62 | constructor(props){ 63 | super(props); 64 | this.state = { 65 | showFiles: false, 66 | showDrawer: false, 67 | showAlert: false, 68 | 69 | editorWidth: document.body.offsetWidth * background.width, 70 | editorWidthPercent: background.width, 71 | 72 | source: undefined, 73 | result: undefined, 74 | // 当前打开文档相关信息 75 | path: undefined, 76 | sha: undefined, 77 | index: -1, 78 | 79 | postsIndex: background.postsIndex || [], 80 | }; 81 | // 节流使用的变量 82 | this.saveInterval = 10000; 83 | this.lastSaveTime = 0; 84 | /** 85 | * @type { path: string,sha:string,lastModified:string } 86 | */ 87 | this.post = null; // 指向 postsIndexList 成员 88 | 89 | const imgErrs = {}; 90 | /** 91 | * 92 | * @param path 图片相对路径 93 | * @param state 上传状态:0 正在上传 1 上传成功 2 上传失败 94 | * @param errMsg 95 | */ 96 | this.handleImageUpload = function (path, state, errMsg) { 97 | console.log(path, state, errMsg) 98 | switch (state) { 99 | case 0: imgErrs[path] = true; break; 100 | case 1: delete imgErrs[path];break; 101 | case 2: imgErrs[path] = errMsg;break; 102 | } 103 | if(state){ 104 | this.parse(this.state.source); 105 | } 106 | } 107 | this.renderer = new Marked.Renderer(); 108 | this.renderer.image = function(href, title, text){ 109 | if (href === null) { 110 | return text; 111 | } 112 | if(imgErrs[href]){ 113 | if(imgErrs[href] === true){ 114 | return `${text}上传中...` 115 | }else{ 116 | return `${text}上传失败, 请重试` 117 | } 118 | } 119 | if(!href.startsWith('http')){ 120 | href = pathJoin(this.options.baseUrl, href) 121 | } 122 | var out = '' + text + '' : '>'; 127 | return out; 128 | } 129 | Marked.setOptions({ 130 | baseUrl: background.baseUrl 131 | }) 132 | } 133 | 134 | parse(data) { 135 | // TODO:// 检查正则表达式,运行就卡死 136 | // let source = data.replace(/^---((\s+.*)*)\s---/, (match, p1) => { 137 | // info = p1; 138 | // return ''; 139 | // }) 140 | let index = data.indexOf('---', 3); 141 | if(index){ 142 | return Marked(data.substr(index+3), {renderer: this.renderer}) 143 | } 144 | return Marked(data, {renderer: this.renderer}) 145 | } 146 | 147 | componentWillMount() { 148 | setListener.bind(this)(); 149 | this.loadData(); 150 | console.log('show time', this.state) 151 | } 152 | 153 | loadData(data){ 154 | let date = DateFormat('yyyy-MM-dd') 155 | let defMeta = `---\nlayout: post\ntitle: ${date}\nsubtitle:\ndate: ${date}\ntags: ['note']\n---\n`; 156 | if(!data){ // 默认新文章 157 | data = { 158 | source: defMeta, 159 | path: pathJoin(background.path, DateFormat("yyyy-MM-dd-HHmmss.'md'")) 160 | } 161 | }else if(!data.source){ // 新建文章 162 | data.source = defMeta; 163 | } 164 | data.result = this.parse(data.source); 165 | this.setState(data); 166 | } 167 | onCreateFile(path){ 168 | this.setState({showFiles: false}) 169 | this.loadData({path}); 170 | } 171 | onFileSelected(file){ 172 | this.setState({showFiles: false}); 173 | if(this.post && this.post.path == file.path) return; 174 | let index = this.state.postsIndex.findIndex(item => item.path == file.path) 175 | if(index != -1){ 176 | this.post = this.state.postsIndex[index]; 177 | this.toast('为你打开的是该文件的本地缓存,请先提交或删除此缓存', 'info'); 178 | background.get(this.post.path).then(res => { 179 | this.loadData({ 180 | path: this.post.path, 181 | sha: this.post.sha, 182 | source: res[this.post.path], 183 | postIndex: index 184 | }) 185 | }); 186 | }else{ 187 | this.showLoading() 188 | background.getContents(file.path, true).then(res => { 189 | let source = null; 190 | if(typeof res.data == 'object'){ 191 | source = atob(res.data.content); 192 | }else{ 193 | source = res.data; 194 | } 195 | this.loadData({source, path: file.path, sha: file.sha}) 196 | this.hideLoading(); 197 | }).catch(e => { 198 | this.hideLoading() 199 | this.toast(e.message, 'error') 200 | }) 201 | } 202 | } 203 | onOpenLocalFile(index){ 204 | if(this.state.index == index) return; 205 | this.post = this.state.postsIndex[index]; 206 | background.get(this.post.path).then(res => { 207 | this.loadData({ 208 | path: this.post.path, 209 | sha: this.post.sha, 210 | source: res[this.post.path], 211 | index: index 212 | }) 213 | }); 214 | } 215 | handleUpdate(value){ 216 | this.setState({source: value, result: this.parse(value)}) 217 | this.cache(value) 218 | } 219 | handleKeyDown(ev){ 220 | switch (ev.type) { 221 | case 'save': 222 | this.saveData() 223 | break 224 | } 225 | } 226 | cache(){ 227 | if(Date.now() - this.lastSaveTime > this.saveInterval){ 228 | this.lastSaveTime = Date.now(); 229 | this.saveData(); 230 | } 231 | } 232 | saveData(){ 233 | background.save({ 234 | [this.state.path]: this.state.source 235 | }).then(res => { 236 | let {path, sha, postsIndex} = this.state; 237 | if(this.post){ 238 | this.post.lastModified = DateFormat(); 239 | }else{ 240 | this.post = postsIndex.find(item => item.path == path); 241 | if(this.post){ 242 | this.post.lastModified = DateFormat(); 243 | }else{ 244 | this.post = {path,sha,lastModified: DateFormat()}; 245 | postsIndex.unshift(this.post) 246 | } 247 | } 248 | this.setState({postsIndex}); 249 | background.save({postsIndex}); 250 | }) 251 | } 252 | onFileCanceled(){ 253 | this.setState({showFiles: false}) 254 | } 255 | showDrawer(){ 256 | this.setState({showDrawer: !this.state.showDrawer}); 257 | } 258 | open(){ 259 | this.setState({ 260 | showFiles: true 261 | }) 262 | } 263 | upload(){ 264 | this.setState({ 265 | showAlert: true 266 | }) 267 | } 268 | handleClose(field){ 269 | this.setState({ 270 | [field]: false 271 | }) 272 | } 273 | deletePostCache(index){ 274 | if(index == this.state.index){ 275 | this.loadData() 276 | } 277 | let path = this.state.postsIndex.splice(index, 1)[0].path; 278 | this.setState({postsIndex: this.state.postsIndex}) 279 | background.save({ 280 | postsIndex: this.state.postsIndex 281 | }) 282 | background.remove(path); 283 | event.preventDefault(); 284 | } 285 | uploadConfirm(){ 286 | this.setState({ 287 | showAlert: false 288 | }) 289 | this.showLoading(); 290 | 291 | background.createContent(this.state.path, this.state.source, this.state.sha).then(res => { 292 | this.toast('保存成功', 'success'); 293 | this.setState({ 294 | sha: res.data.content.sha 295 | }) 296 | 297 | this.state.postsIndex.splice(this.state.index, 1); 298 | this.setState({postsIndex: this.state.postsIndex}) 299 | background.save({postsIndex: this.state.postsIndex}) 300 | background.remove(this.state.path); 301 | this.loadData(); 302 | }).catch(err => { 303 | this.toast(err.message, 'error'); 304 | }).then(()=>{ 305 | this.hideLoading(); 306 | }) 307 | } 308 | showLoading(){ 309 | this.setState({ 310 | progress:
    311 | 312 |
    313 | }) 314 | } 315 | hideLoading(){ 316 | this.setState({ 317 | progress: null 318 | }) 319 | } 320 | toast(message, variant){ 321 | // variant could be success, error, warning or info 322 | this.props.enqueueSnackbar(message, { variant }); 323 | }; 324 | render() { 325 | const {classes} = this.props; 326 | const {progress, showFiles, showDrawer, editorWidth, source, result, postsIndex} = this.state; 327 | return ( 328 | 329 |
    330 | 331 | {progress} 332 | {showFiles && } 339 | 347 | 348 | 349 | 这里是存储在扩展中还没有同步到Github的文章 350 | 351 | 352 | 353 | {postsIndex.map((posts, index) => ( 354 | 357 | {} 358 | 361 | 362 | ))} 363 | 364 | 365 |
    366 | this.loadData()} 369 | local={this.showDrawer.bind(this)} 370 | open={this.open.bind(this)} 371 | upload={this.upload.bind(this)} 372 | /> 373 | 374 |
    375 |
    376 | 383 |
    384 |
    385 |
    386 |
    387 |
    388 |
    {this.state.path}
    389 |
    390 | 391 | 394 | {"提交到 GitHub?"} 395 | 396 | 397 | 提交当前文件到 Github,同时会删除本地缓存 398 | 399 | 400 | 401 | 404 | 407 | 408 | 409 |
    410 | ); 411 | } 412 | } 413 | 414 | let drawerWidth = 320; 415 | const styles = theme => ({ 416 | root: { 417 | height: '100vh', width: '100vw' 418 | }, 419 | path: { 420 | position: 'absolute', padding: '2px 8px', 421 | left: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,.2)', color: '#b3b3b3' 422 | }, 423 | drawer: { 424 | width: drawerWidth, 425 | flexShrink: 0, 426 | }, 427 | drawerPaper: { 428 | width: drawerWidth, 429 | backgroundColor: '#2e2e2e' 430 | }, 431 | drawerHeader: { 432 | padding: theme.spacing.unit 433 | }, 434 | textColor: {color: '#b3b3b3'}, 435 | 436 | content: { 437 | width: '100%', height: '100%', 438 | display: 'flex', flexDirection: 'column', 439 | }, 440 | listItem: { 441 | fontSize: 50 442 | }, 443 | 444 | editorBar: { 445 | flex: '0 0 auto' 446 | }, 447 | editorWrapper: { 448 | flex: '1 1 0', 449 | overflow: 'hidden', 450 | color: '#b3b3b3', 451 | backgroundColor: '#3d3d3d', 452 | display: 'flex', 453 | flexDirection: 'row' 454 | }, 455 | left: { 456 | flex: '0 0 auto', 457 | display: 'flex', 458 | flexDirection: 'column', 459 | overflow: 'hidden' 460 | }, 461 | divider: { 462 | flex: '0 0 auto', 463 | width: '4px', 464 | backgroundColor: 'rgba(0,0,0,.6)', 465 | cursor: 'col-resize' 466 | }, 467 | right: { 468 | flex: '1 1 0', 469 | padding: '20px 40px 80px', 470 | backgroundColor: '#4d4d4d', 471 | fontSize: 16, 472 | overflow: 'auto' 473 | }, 474 | editor: {flex: '1 1 0'}, 475 | globalProgress: { 476 | position: 'fixed', top: 0, right: 0, bottom: 0, left: 0, backgroundColor: 'rgba(0,0,0,.6)', 477 | display: 'flex', justifyContent: 'center', alignItems: 'center' 478 | }, 479 | iconDelete: { 480 | color: theme.palette.secondary.main 481 | } 482 | }) 483 | 484 | export default withSnackbar(withStyles(styles, { withTheme: true })(MDEditor)); 485 | -------------------------------------------------------------------------------- /src/views/editor/style.css: -------------------------------------------------------------------------------- 1 | /*! normalize.css v3.0.2 | MIT License | git.io/normalize */ 2 | /** 3 | * 1. Set default font family to sans-serif. 4 | * 2. Prevent iOS text size adjust after orientation change, without disabling 5 | * user zoom. 6 | */ 7 | @import url("https://fonts.googleapis.com/css?family=Open+Sans:400,700"); 8 | html { 9 | font-family: sans-serif; 10 | /* 1 */ 11 | -ms-text-size-adjust: 100%; 12 | /* 2 */ 13 | -webkit-text-size-adjust: 100%; 14 | /* 2 */ } 15 | 16 | /** 17 | * Remove default margin. 18 | */ 19 | body { 20 | margin: 0; } 21 | 22 | /* HTML5 display definitions 23 | ========================================================================== */ 24 | /** 25 | * Correct `block` display not defined for any HTML5 element in IE 8/9. 26 | * Correct `block` display not defined for `details` or `summary` in IE 10/11 27 | * and Firefox. 28 | * Correct `block` display not defined for `main` in IE 11. 29 | */ 30 | article, 31 | aside, 32 | details, 33 | figcaption, 34 | figure, 35 | footer, 36 | header, 37 | hgroup, 38 | main, 39 | menu, 40 | nav, 41 | section, 42 | summary { 43 | display: block; } 44 | 45 | /** 46 | * 1. Correct `inline-block` display not defined in IE 8/9. 47 | * 2. Normalize vertical alignment of `progress` in Chrome, Firefox, and Opera. 48 | */ 49 | audio, 50 | canvas, 51 | progress, 52 | video { 53 | display: inline-block; 54 | /* 1 */ 55 | vertical-align: baseline; 56 | /* 2 */ } 57 | 58 | /** 59 | * Prevent modern browsers from displaying `audio` without controls. 60 | * Remove excess height in iOS 5 devices. 61 | */ 62 | audio:not([controls]) { 63 | display: none; 64 | height: 0; } 65 | 66 | /** 67 | * Address `[hidden]` styling not present in IE 8/9/10. 68 | * Hide the `template` element in IE 8/9/11, Safari, and Firefox < 22. 69 | */ 70 | [hidden], 71 | template { 72 | display: none; } 73 | 74 | /* Links 75 | ========================================================================== */ 76 | /** 77 | * Remove the gray background color from active links in IE 10. 78 | */ 79 | a { 80 | background-color: transparent; } 81 | 82 | /** 83 | * Improve readability when focused and also mouse hovered in all browsers. 84 | */ 85 | a:active, 86 | a:hover { 87 | outline: 0; } 88 | 89 | /* Text-level semantics 90 | ========================================================================== */ 91 | /** 92 | * Address styling not present in IE 8/9/10/11, Safari, and Chrome. 93 | */ 94 | abbr[title] { 95 | border-bottom: 1px dotted; } 96 | 97 | /** 98 | * Address style set to `bolder` in Firefox 4+, Safari, and Chrome. 99 | */ 100 | b, 101 | strong { 102 | font-weight: bold; } 103 | 104 | /** 105 | * Address styling not present in Safari and Chrome. 106 | */ 107 | dfn { 108 | font-style: italic; } 109 | 110 | /** 111 | * Address variable `h1` font-size and margin within `section` and `article` 112 | * contexts in Firefox 4+, Safari, and Chrome. 113 | */ 114 | h1 { 115 | font-size: 2em; 116 | margin: 0.67em 0; } 117 | 118 | /** 119 | * Address styling not present in IE 8/9. 120 | */ 121 | mark { 122 | background: #ff0; 123 | color: #000; } 124 | 125 | /** 126 | * Address inconsistent and variable font size in all browsers. 127 | */ 128 | small { 129 | font-size: 80%; } 130 | 131 | /** 132 | * Prevent `sub` and `sup` affecting `line-height` in all browsers. 133 | */ 134 | sub, 135 | sup { 136 | font-size: 75%; 137 | line-height: 0; 138 | position: relative; 139 | vertical-align: baseline; } 140 | 141 | sup { 142 | top: -0.5em; } 143 | 144 | sub { 145 | bottom: -0.25em; } 146 | 147 | /* Embedded content 148 | ========================================================================== */ 149 | /** 150 | * Remove border when inside `a` element in IE 8/9/10. 151 | */ 152 | img { 153 | border: 0; } 154 | 155 | /** 156 | * Correct overflow not hidden in IE 9/10/11. 157 | */ 158 | svg:not(:root) { 159 | overflow: hidden; } 160 | 161 | /* Grouping content 162 | ========================================================================== */ 163 | /** 164 | * Address margin not present in IE 8/9 and Safari. 165 | */ 166 | figure { 167 | margin: 1em 40px; } 168 | 169 | /** 170 | * Address differences between Firefox and other browsers. 171 | */ 172 | hr { 173 | box-sizing: content-box; 174 | height: 0; } 175 | 176 | /** 177 | * Contain overflow in all browsers. 178 | */ 179 | pre { 180 | overflow: auto; } 181 | 182 | /** 183 | * Address odd `em`-unit font size rendering in all browsers. 184 | */ 185 | code, 186 | kbd, 187 | pre, 188 | samp { 189 | font-family: monospace, monospace; 190 | font-size: 1em; } 191 | 192 | /* Forms 193 | ========================================================================== */ 194 | /** 195 | * Known limitation: by default, Chrome and Safari on OS X allow very limited 196 | * styling of `select`, unless a `border` property is set. 197 | */ 198 | /** 199 | * 1. Correct color not being inherited. 200 | * Known issue: affects color of disabled elements. 201 | * 2. Correct font properties not being inherited. 202 | * 3. Address margins set differently in Firefox 4+, Safari, and Chrome. 203 | */ 204 | button, 205 | input, 206 | optgroup, 207 | select, 208 | textarea { 209 | color: inherit; 210 | /* 1 */ 211 | font: inherit; 212 | /* 2 */ 213 | margin: 0; 214 | /* 3 */ } 215 | 216 | /** 217 | * Address `overflow` set to `hidden` in IE 8/9/10/11. 218 | */ 219 | button { 220 | overflow: visible; } 221 | 222 | /** 223 | * Address inconsistent `text-transform` inheritance for `button` and `select`. 224 | * All other form control elements do not inherit `text-transform` values. 225 | * Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera. 226 | * Correct `select` style inheritance in Firefox. 227 | */ 228 | button, 229 | select { 230 | text-transform: none; } 231 | 232 | /** 233 | * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio` 234 | * and `video` controls. 235 | * 2. Correct inability to style clickable `input` types in iOS. 236 | * 3. Improve usability and consistency of cursor style between image-type 237 | * `input` and others. 238 | */ 239 | button, 240 | html input[type="button"], 241 | input[type="reset"], 242 | input[type="submit"] { 243 | -webkit-appearance: button; 244 | /* 2 */ 245 | cursor: pointer; 246 | /* 3 */ } 247 | 248 | /** 249 | * Re-set default cursor for disabled elements. 250 | */ 251 | button[disabled], 252 | html input[disabled] { 253 | cursor: default; } 254 | 255 | /** 256 | * Remove inner padding and border in Firefox 4+. 257 | */ 258 | button::-moz-focus-inner, 259 | input::-moz-focus-inner { 260 | border: 0; 261 | padding: 0; } 262 | 263 | /** 264 | * Address Firefox 4+ setting `line-height` on `input` using `!important` in 265 | * the UA stylesheet. 266 | */ 267 | input { 268 | line-height: normal; } 269 | 270 | /** 271 | * It's recommended that you don't attempt to style these elements. 272 | * Firefox's implementation doesn't respect box-sizing, padding, or width. 273 | * 274 | * 1. Address box sizing set to `content-box` in IE 8/9/10. 275 | * 2. Remove excess padding in IE 8/9/10. 276 | */ 277 | input[type="checkbox"], 278 | input[type="radio"] { 279 | box-sizing: border-box; 280 | /* 1 */ 281 | padding: 0; 282 | /* 2 */ } 283 | 284 | /** 285 | * Fix the cursor style for Chrome's increment/decrement buttons. For certain 286 | * `font-size` values of the `input`, it causes the cursor style of the 287 | * decrement button to change from `default` to `text`. 288 | */ 289 | input[type="number"]::-webkit-inner-spin-button, 290 | input[type="number"]::-webkit-outer-spin-button { 291 | height: auto; } 292 | 293 | /** 294 | * 1. Address `appearance` set to `searchfield` in Safari and Chrome. 295 | * 2. Address `box-sizing` set to `border-box` in Safari and Chrome 296 | * (include `-moz` to future-proof). 297 | */ 298 | input[type="search"] { 299 | -webkit-appearance: textfield; 300 | /* 1 */ 301 | /* 2 */ 302 | box-sizing: content-box; } 303 | 304 | /** 305 | * Remove inner padding and search cancel button in Safari and Chrome on OS X. 306 | * Safari (but not Chrome) clips the cancel button when the search input has 307 | * padding (and `textfield` appearance). 308 | */ 309 | input[type="search"]::-webkit-search-cancel-button, 310 | input[type="search"]::-webkit-search-decoration { 311 | -webkit-appearance: none; } 312 | 313 | /** 314 | * Define consistent border, margin, and padding. 315 | */ 316 | fieldset { 317 | border: 1px solid #c0c0c0; 318 | margin: 0 2px; 319 | padding: 0.35em 0.625em 0.75em; } 320 | 321 | /** 322 | * 1. Correct `color` not being inherited in IE 8/9/10/11. 323 | * 2. Remove padding so people aren't caught out if they zero out fieldsets. 324 | */ 325 | legend { 326 | border: 0; 327 | /* 1 */ 328 | padding: 0; 329 | /* 2 */ } 330 | 331 | /** 332 | * Remove default vertical scrollbar in IE 8/9/10/11. 333 | */ 334 | textarea { 335 | overflow: auto; } 336 | 337 | /** 338 | * Don't inherit the `font-weight` (applied by a rule above). 339 | * NOTE: the default cannot safely be changed in Chrome and Safari on OS X. 340 | */ 341 | optgroup { 342 | font-weight: bold; } 343 | 344 | /* Tables 345 | ========================================================================== */ 346 | /** 347 | * Remove most spacing between table cells. 348 | */ 349 | table { 350 | border-collapse: collapse; 351 | border-spacing: 0; } 352 | 353 | td, 354 | th { 355 | padding: 0; } 356 | 357 | .highlight table td { 358 | padding: 5px; } 359 | 360 | .highlight table pre { 361 | margin: 0; } 362 | 363 | .highlight .cm { 364 | color: #999988; 365 | font-style: italic; } 366 | 367 | .highlight .cp { 368 | color: #999999; 369 | font-weight: bold; } 370 | 371 | .highlight .c1 { 372 | color: #999988; 373 | font-style: italic; } 374 | 375 | .highlight .cs { 376 | color: #999999; 377 | font-weight: bold; 378 | font-style: italic; } 379 | 380 | .highlight .c, .highlight .cd { 381 | color: #999988; 382 | font-style: italic; } 383 | 384 | .highlight .err { 385 | color: #a61717; 386 | background-color: #e3d2d2; } 387 | 388 | .highlight .gd { 389 | color: #000000; 390 | background-color: #ffdddd; } 391 | 392 | .highlight .ge { 393 | color: #000000; 394 | font-style: italic; } 395 | 396 | .highlight .gr { 397 | color: #aa0000; } 398 | 399 | .highlight .gh { 400 | color: #999999; } 401 | 402 | .highlight .gi { 403 | color: #000000; 404 | background-color: #ddffdd; } 405 | 406 | .highlight .go { 407 | color: #888888; } 408 | 409 | .highlight .gp { 410 | color: #555555; } 411 | 412 | .highlight .gs { 413 | font-weight: bold; } 414 | 415 | .highlight .gu { 416 | color: #aaaaaa; } 417 | 418 | .highlight .gt { 419 | color: #aa0000; } 420 | 421 | .highlight .kc { 422 | color: #000000; 423 | font-weight: bold; } 424 | 425 | .highlight .kd { 426 | color: #000000; 427 | font-weight: bold; } 428 | 429 | .highlight .kn { 430 | color: #000000; 431 | font-weight: bold; } 432 | 433 | .highlight .kp { 434 | color: #000000; 435 | font-weight: bold; } 436 | 437 | .highlight .kr { 438 | color: #000000; 439 | font-weight: bold; } 440 | 441 | .highlight .kt { 442 | color: #445588; 443 | font-weight: bold; } 444 | 445 | .highlight .k, .highlight .kv { 446 | color: #000000; 447 | font-weight: bold; } 448 | 449 | .highlight .mf { 450 | color: #009999; } 451 | 452 | .highlight .mh { 453 | color: #009999; } 454 | 455 | .highlight .il { 456 | color: #009999; } 457 | 458 | .highlight .mi { 459 | color: #009999; } 460 | 461 | .highlight .mo { 462 | color: #009999; } 463 | 464 | .highlight .m, .highlight .mb, .highlight .mx { 465 | color: #009999; } 466 | 467 | .highlight .sb { 468 | color: #d14; } 469 | 470 | .highlight .sc { 471 | color: #d14; } 472 | 473 | .highlight .sd { 474 | color: #d14; } 475 | 476 | .highlight .s2 { 477 | color: #d14; } 478 | 479 | .highlight .se { 480 | color: #d14; } 481 | 482 | .highlight .sh { 483 | color: #d14; } 484 | 485 | .highlight .si { 486 | color: #d14; } 487 | 488 | .highlight .sx { 489 | color: #d14; } 490 | 491 | .highlight .sr { 492 | color: #009926; } 493 | 494 | .highlight .s1 { 495 | color: #d14; } 496 | 497 | .highlight .ss { 498 | color: #990073; } 499 | 500 | .highlight .s { 501 | color: #d14; } 502 | 503 | .highlight .na { 504 | color: #008080; } 505 | 506 | .highlight .bp { 507 | color: #999999; } 508 | 509 | .highlight .nb { 510 | color: #0086B3; } 511 | 512 | .highlight .nc { 513 | color: #445588; 514 | font-weight: bold; } 515 | 516 | .highlight .no { 517 | color: #008080; } 518 | 519 | .highlight .nd { 520 | color: #3c5d5d; 521 | font-weight: bold; } 522 | 523 | .highlight .ni { 524 | color: #800080; } 525 | 526 | .highlight .ne { 527 | color: #990000; 528 | font-weight: bold; } 529 | 530 | .highlight .nf { 531 | color: #990000; 532 | font-weight: bold; } 533 | 534 | .highlight .nl { 535 | color: #990000; 536 | font-weight: bold; } 537 | 538 | .highlight .nn { 539 | color: #555555; } 540 | 541 | .highlight .nt { 542 | color: #000080; } 543 | 544 | .highlight .vc { 545 | color: #008080; } 546 | 547 | .highlight .vg { 548 | color: #008080; } 549 | 550 | .highlight .vi { 551 | color: #008080; } 552 | 553 | .highlight .nv { 554 | color: #008080; } 555 | 556 | .highlight .ow { 557 | color: #000000; 558 | font-weight: bold; } 559 | 560 | .highlight .o { 561 | color: #000000; 562 | font-weight: bold; } 563 | 564 | .highlight .w { 565 | color: #bbbbbb; } 566 | 567 | .highlight { 568 | background-color: #f8f8f8; } 569 | 570 | * { 571 | box-sizing: border-box; } 572 | 573 | body { 574 | padding: 0; 575 | margin: 0; 576 | font-family: "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif; 577 | font-size: 16px; 578 | line-height: 1.5; 579 | color: #606c71; } 580 | 581 | #skip-to-content { 582 | height: 1px; 583 | width: 1px; 584 | position: absolute; 585 | overflow: hidden; 586 | top: -10px; } 587 | #skip-to-content:focus { 588 | position: fixed; 589 | top: 10px; 590 | left: 10px; 591 | height: auto; 592 | width: auto; 593 | background: #e19447; 594 | outline: thick solid #e19447; } 595 | 596 | a { 597 | color: #1e6bb8; 598 | text-decoration: none; } 599 | a:hover { 600 | text-decoration: underline; } 601 | 602 | .btn { 603 | display: inline-block; 604 | margin-bottom: 1rem; 605 | color: rgba(255, 255, 255, 0.7); 606 | background-color: rgba(255, 255, 255, 0.08); 607 | border-color: rgba(255, 255, 255, 0.2); 608 | border-style: solid; 609 | border-width: 1px; 610 | border-radius: 0.3rem; 611 | transition: color 0.2s, background-color 0.2s, border-color 0.2s; } 612 | .btn:hover { 613 | color: rgba(255, 255, 255, 0.8); 614 | text-decoration: none; 615 | background-color: rgba(255, 255, 255, 0.2); 616 | border-color: rgba(255, 255, 255, 0.3); } 617 | .btn + .btn { 618 | margin-left: 1rem; } 619 | @media screen and (min-width: 64em) { 620 | .btn { 621 | padding: 0.75rem 1rem; } } 622 | @media screen and (min-width: 42em) and (max-width: 64em) { 623 | .btn { 624 | padding: 0.6rem 0.9rem; 625 | font-size: 0.9rem; } } 626 | @media screen and (max-width: 42em) { 627 | .btn { 628 | display: block; 629 | width: 100%; 630 | padding: 0.75rem; 631 | font-size: 0.9rem; } 632 | .btn + .btn { 633 | margin-top: 1rem; 634 | margin-left: 0; } } 635 | 636 | .page-header { 637 | color: #fff; 638 | text-align: center; 639 | background-color: #159957; 640 | background-image: linear-gradient(120deg, #155799, #159957); } 641 | @media screen and (min-width: 64em) { 642 | .page-header { 643 | padding: 5rem 6rem; } } 644 | @media screen and (min-width: 42em) and (max-width: 64em) { 645 | .page-header { 646 | padding: 3rem 4rem; } } 647 | @media screen and (max-width: 42em) { 648 | .page-header { 649 | padding: 2rem 1rem; } } 650 | 651 | .project-name { 652 | margin-top: 0; 653 | margin-bottom: 0.1rem; } 654 | @media screen and (min-width: 64em) { 655 | .project-name { 656 | font-size: 3.25rem; } } 657 | @media screen and (min-width: 42em) and (max-width: 64em) { 658 | .project-name { 659 | font-size: 2.25rem; } } 660 | @media screen and (max-width: 42em) { 661 | .project-name { 662 | font-size: 1.75rem; } } 663 | 664 | .project-tagline { 665 | margin-bottom: 2rem; 666 | font-weight: normal; 667 | opacity: 0.7; } 668 | @media screen and (min-width: 64em) { 669 | .project-tagline { 670 | font-size: 1.25rem; } } 671 | @media screen and (min-width: 42em) and (max-width: 64em) { 672 | .project-tagline { 673 | font-size: 1.15rem; } } 674 | @media screen and (max-width: 42em) { 675 | .project-tagline { 676 | font-size: 1rem; } } 677 | 678 | .main-content { 679 | word-wrap: break-word; } 680 | .main-content :first-child { 681 | margin-top: 0; } 682 | @media screen and (min-width: 64em) { 683 | .main-content { 684 | max-width: 64rem; 685 | padding: 2rem 6rem; 686 | margin: 0 auto; 687 | font-size: 1.1rem; } } 688 | @media screen and (min-width: 42em) and (max-width: 64em) { 689 | .main-content { 690 | padding: 2rem 4rem; 691 | font-size: 1.1rem; } } 692 | @media screen and (max-width: 42em) { 693 | .main-content { 694 | padding: 2rem 1rem; 695 | font-size: 1rem; } } 696 | .main-content kbd { 697 | background-color: #fafbfc; 698 | border: 1px solid #c6cbd1; 699 | border-bottom-color: #959da5; 700 | border-radius: 3px; 701 | box-shadow: inset 0 -1px 0 #959da5; 702 | color: #444d56; 703 | display: inline-block; 704 | font-size: 11px; 705 | line-height: 10px; 706 | padding: 3px 5px; 707 | vertical-align: middle; } 708 | .main-content img { 709 | max-width: 100%; } 710 | .main-content h1, 711 | .main-content h2, 712 | .main-content h3, 713 | .main-content h4, 714 | .main-content h5, 715 | .main-content h6 { 716 | margin-top: 2rem; 717 | margin-bottom: 1rem; 718 | font-weight: normal; 719 | color: #159957; } 720 | .main-content p { 721 | margin-bottom: 1em; } 722 | .main-content code { 723 | padding: 2px 4px; 724 | font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace; 725 | font-size: 0.9rem; 726 | color: #567482; 727 | background-color: #f3f6fa; 728 | border-radius: 0.3rem; } 729 | .main-content pre { 730 | padding: 0.8rem; 731 | margin-top: 0; 732 | margin-bottom: 1rem; 733 | font: 1rem Consolas, "Liberation Mono", Menlo, Courier, monospace; 734 | color: #567482; 735 | word-wrap: normal; 736 | background-color: #f3f6fa; 737 | border: solid 1px #dce6f0; 738 | border-radius: 0.3rem; } 739 | .main-content pre > code { 740 | padding: 0; 741 | margin: 0; 742 | font-size: 0.9rem; 743 | color: #567482; 744 | word-break: normal; 745 | white-space: pre; 746 | background: transparent; 747 | border: 0; } 748 | .main-content .highlight { 749 | margin-bottom: 1rem; } 750 | .main-content .highlight pre { 751 | margin-bottom: 0; 752 | word-break: normal; } 753 | .main-content .highlight pre, 754 | .main-content pre { 755 | padding: 0.8rem; 756 | overflow: auto; 757 | font-size: 0.9rem; 758 | line-height: 1.45; 759 | border-radius: 0.3rem; 760 | -webkit-overflow-scrolling: touch; } 761 | .main-content pre code, 762 | .main-content pre tt { 763 | display: inline; 764 | max-width: initial; 765 | padding: 0; 766 | margin: 0; 767 | overflow: initial; 768 | line-height: inherit; 769 | word-wrap: normal; 770 | background-color: transparent; 771 | border: 0; } 772 | .main-content pre code:before, .main-content pre code:after, 773 | .main-content pre tt:before, 774 | .main-content pre tt:after { 775 | content: normal; } 776 | .main-content ul, 777 | .main-content ol { 778 | margin-top: 0; } 779 | .main-content blockquote { 780 | padding: 0 1rem; 781 | margin-left: 0; 782 | color: #819198; 783 | border-left: 0.3rem solid #dce6f0; } 784 | .main-content blockquote > :first-child { 785 | margin-top: 0; } 786 | .main-content blockquote > :last-child { 787 | margin-bottom: 0; } 788 | .main-content table { 789 | display: block; 790 | width: 100%; 791 | overflow: auto; 792 | word-break: normal; 793 | word-break: keep-all; 794 | -webkit-overflow-scrolling: touch; } 795 | .main-content table th { 796 | font-weight: bold; } 797 | .main-content table th, 798 | .main-content table td { 799 | padding: 0.5rem 1rem; 800 | border: 1px solid #e9ebec; } 801 | .main-content dl { 802 | padding: 0; } 803 | .main-content dl dt { 804 | padding: 0; 805 | margin-top: 1rem; 806 | font-size: 1rem; 807 | font-weight: bold; } 808 | .main-content dl dd { 809 | padding: 0; 810 | margin-bottom: 1rem; } 811 | .main-content hr { 812 | height: 2px; 813 | padding: 0; 814 | margin: 1rem 0; 815 | background-color: #eff0f1; 816 | border: 0; } 817 | 818 | .site-footer { 819 | padding-top: 2rem; 820 | margin-top: 2rem; 821 | border-top: solid 1px #eff0f1; } 822 | @media screen and (min-width: 64em) { 823 | .site-footer { 824 | font-size: 1rem; } } 825 | @media screen and (min-width: 42em) and (max-width: 64em) { 826 | .site-footer { 827 | font-size: 1rem; } } 828 | @media screen and (max-width: 42em) { 829 | .site-footer { 830 | font-size: 0.9rem; } } 831 | 832 | .site-footer-owner { 833 | display: block; 834 | font-weight: bold; } 835 | 836 | .site-footer-credits { 837 | color: #819198; } 838 | --------------------------------------------------------------------------------