├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── .gitignore ├── .prettierrc.js ├── LICENSE ├── README.md ├── lerna.json ├── package.json ├── packages ├── cli │ ├── .editorconfig │ ├── .gitignore │ ├── README.md │ ├── bin │ │ ├── run │ │ └── run.cmd │ ├── package.json │ ├── src │ │ ├── base.ts │ │ ├── commands │ │ │ ├── build.ts │ │ │ ├── commit.ts │ │ │ ├── destroy.ts │ │ │ ├── init.ts │ │ │ ├── pull.ts │ │ │ ├── push.ts │ │ │ ├── reload.ts │ │ │ ├── sync.ts │ │ │ └── up.ts │ │ ├── config.ts │ │ ├── constants.ts │ │ ├── index.ts │ │ └── utils │ │ │ ├── __test__ │ │ │ └── index.test.ts │ │ │ ├── collection.ts │ │ │ ├── git.ts │ │ │ ├── index.ts │ │ │ ├── internals.ts │ │ │ ├── logger.ts │ │ │ └── nodes.ts │ └── tsconfig.json ├── core │ ├── package.json │ ├── src │ │ ├── constants.ts │ │ ├── diff.ts │ │ ├── heading.ts │ │ ├── index.ts │ │ ├── interfaces.ts │ │ ├── steps.ts │ │ ├── utils.ts │ │ └── version.ts │ └── tsconfig.json ├── editor │ ├── .editorconfig │ ├── .eslintignore │ ├── .gitignore │ ├── .prettierrc.js │ ├── README.md │ ├── api-server.js │ ├── config-overrides.js │ ├── fixtures │ │ ├── .tuture │ │ │ ├── collection.json │ │ │ └── diff.json │ │ └── mock-remotes.json │ ├── package.json │ ├── public │ │ ├── favicon.ico │ │ ├── index.html │ │ ├── logo192.png │ │ ├── logo512.png │ │ ├── manifest.json │ │ └── robots.txt │ ├── src │ │ ├── assets │ │ │ ├── antd-custom.less │ │ │ └── images │ │ │ │ └── logo.svg │ │ ├── components │ │ │ ├── App.tsx │ │ │ ├── ConnectedLayout │ │ │ │ ├── ChildrenDrawerComponent │ │ │ │ │ ├── CreateEditArticle.tsx │ │ │ │ │ └── index.tsx │ │ │ │ ├── DrawerComponent │ │ │ │ │ ├── CollectionCatalogue.tsx │ │ │ │ │ ├── CollectionSetting.tsx │ │ │ │ │ ├── ContactUs.tsx │ │ │ │ │ └── index.tsx │ │ │ │ ├── LayoutHeader │ │ │ │ │ ├── LastSavedTimestamp.tsx │ │ │ │ │ ├── SyncModal.tsx │ │ │ │ │ ├── Toolbar │ │ │ │ │ │ ├── BlockButton.tsx │ │ │ │ │ │ ├── Button.tsx │ │ │ │ │ │ ├── EditLink.tsx │ │ │ │ │ │ ├── HistoryButton.tsx │ │ │ │ │ │ ├── HrButton.tsx │ │ │ │ │ │ ├── ImageButton.tsx │ │ │ │ │ │ ├── LinkButton.tsx │ │ │ │ │ │ ├── MarkButton.tsx │ │ │ │ │ │ ├── Menu.tsx │ │ │ │ │ │ ├── NoteButton.tsx │ │ │ │ │ │ ├── SaveButton.tsx │ │ │ │ │ │ ├── SelectContentType.tsx │ │ │ │ │ │ ├── SplitLine.tsx │ │ │ │ │ │ ├── ToolbarIcon.tsx │ │ │ │ │ │ └── index.tsx │ │ │ │ │ └── index.tsx │ │ │ │ ├── MainMenu.tsx │ │ │ │ ├── OutdatedNotification.tsx │ │ │ │ └── index.tsx │ │ │ ├── Content │ │ │ │ ├── Editure │ │ │ │ │ ├── element │ │ │ │ │ │ ├── CodeBlock.tsx │ │ │ │ │ │ ├── DiffBlock.tsx │ │ │ │ │ │ ├── Explain.tsx │ │ │ │ │ │ ├── Hr.tsx │ │ │ │ │ │ ├── Image.tsx │ │ │ │ │ │ ├── ListItem.tsx │ │ │ │ │ │ ├── Note.tsx │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── leaf.tsx │ │ │ │ ├── Highlight │ │ │ │ │ ├── create-element.tsx │ │ │ │ │ ├── highlight.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── languages │ │ │ │ │ │ └── supported-languages.ts │ │ │ │ │ └── styles │ │ │ │ │ │ └── atom-dark.ts │ │ │ │ ├── PageCatalogue.tsx │ │ │ │ ├── PageHeader.tsx │ │ │ │ ├── StepFileList.tsx │ │ │ │ └── index.tsx │ │ │ ├── IconFont.tsx │ │ │ ├── ScrollToTop.ts │ │ │ ├── Toc │ │ │ │ ├── ArticleStepList.tsx │ │ │ │ ├── ReleasedStepList.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── styles.ts │ │ │ │ └── widgets │ │ │ │ │ ├── AddButton.tsx │ │ │ │ │ ├── Caret.tsx │ │ │ │ │ ├── DeleteButton.tsx │ │ │ │ │ └── OutdatedTag.tsx │ │ │ └── index.ts │ │ ├── index.tsx │ │ ├── models │ │ │ ├── collection.ts │ │ │ ├── diff.ts │ │ │ ├── drawer.ts │ │ │ ├── index.ts │ │ │ ├── link.ts │ │ │ ├── slate.ts │ │ │ ├── sync.tsx │ │ │ ├── toc.ts │ │ │ └── util.d.ts │ │ ├── pages │ │ │ ├── Article.tsx │ │ │ ├── Home.tsx │ │ │ └── Toc.tsx │ │ ├── react-app-env.d.ts │ │ ├── serviceWorker.ts │ │ ├── setupTests.ts │ │ ├── shared │ │ │ └── styles.tsx │ │ ├── store │ │ │ └── index.ts │ │ └── utils │ │ │ ├── constants.ts │ │ │ ├── editor.ts │ │ │ ├── environment.ts │ │ │ ├── hiddenLines.ts │ │ │ ├── hooks.ts │ │ │ ├── hotkeys.ts │ │ │ ├── image.ts │ │ │ ├── note.ts │ │ │ └── request.ts │ └── tsconfig.json └── local-server │ ├── package.json │ ├── src │ ├── index.ts │ ├── routes │ │ ├── articles.ts │ │ ├── collection-steps.ts │ │ ├── diff.ts │ │ ├── fragment.ts │ │ ├── index.ts │ │ ├── meta.ts │ │ ├── remotes.ts │ │ └── toc.ts │ ├── server.ts │ ├── types.ts │ └── utils │ │ ├── assets.ts │ │ ├── collection.ts │ │ ├── index.ts │ │ └── task-queue.ts │ └── tsconfig.json ├── rollup.config.js ├── tsconfig.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | *.md 2 | *.html 3 | *.json 4 | .github 5 | build 6 | dist 7 | node_modules 8 | tmp 9 | yarn.lock 10 | package.json 11 | test 12 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'plugin:import/typescript', 4 | 'prettier', 5 | 'prettier/@typescript-eslint', 6 | 'prettier/react', 7 | ], 8 | plugins: ['@typescript-eslint', 'import', 'react', 'prettier'], 9 | parser: '@typescript-eslint/parser', 10 | parserOptions: { 11 | sourceType: 'module', 12 | ecmaVersion: 2020, 13 | ecmaFeatures: { 14 | jsx: true, 15 | }, 16 | }, 17 | settings: { 18 | 'import/extensions': ['.js', '.ts', '.jsx', '.tsx'], 19 | react: { 20 | version: 'detect', 21 | }, 22 | }, 23 | env: { 24 | browser: true, 25 | es6: true, 26 | node: true, 27 | }, 28 | }; 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug 反馈 3 | about: 创建一份 Bug 反馈报告,帮助我们改进 4 | title: '' 5 | labels: 'bug' 6 | assignees: '' 7 | --- 8 | 9 | **简要描述 Bug** 10 | 一句话描述一下 Bug 的情况。 11 | 12 | **Tuture 版本信息** 13 | 例如 `@tuture/cli/0.0.2 darwin-x64 node-v12.10.0`,可通过 `tuture -v` 查看版本。 14 | 15 | **详细的报错调用栈信息** 16 | 终端或者浏览器控制台的任何报错信息,请直接复制在这里。 17 | 18 | **复现步骤** 19 | 复现 Bug 的完整步骤: 20 | 21 | 1. 打开 ... 22 | 2. 点击 ... 23 | 3. 发生错误 24 | 25 | **期望的行为逻辑** 26 | 描述一下你本来期望发生的事情。 27 | 28 | **屏幕截图** 29 | 出错时的屏幕截图(有 GIF 的话就更棒啦!) 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 功能/需求 3 | about: 想要什么样的新功能,或者觉得哪些地方可以改进? 4 | title: '' 5 | labels: 'enhancement' 6 | assignees: '' 7 | --- 8 | 9 | **Tuture 版本信息** 10 | 例如 `@tuture/cli/0.0.2 darwin-x64 node-v12.10.0`,可通过 `tuture -v` 查看版本。 11 | 12 | **对目前工具的哪些地方不满意?** 13 | 简要地描述一下让你不爽的问题(如果有截图就更好啦)。 14 | 15 | **描述一下你期望的功能** 16 | 简要地描述一下你所期望的功能,或者更理想的解决方案。 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | 23 | # not use npm 24 | package-lock.json 25 | 26 | # dist files should not include to the SCM 27 | dist 28 | 29 | tuture-error.log 30 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | bracketSpacing: true, 3 | singleQuote: true, 4 | jsxBracketSameLine: true, 5 | trailingComma: 'all', 6 | printWidth: 80, 7 | arrowParens: 'always', 8 | }; 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Tuture Authors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": ["packages/core", "packages/local-server", "packages/cli"], 3 | "npmClient": "yarn", 4 | "useWorkspaces": true, 5 | "version": "0.0.7", 6 | "command": { 7 | "publish": { 8 | "ignoreChanges": ["*.md"] 9 | }, 10 | "bootstrap": { 11 | "npmClientArgs": ["--no-lockfile"] 12 | } 13 | }, 14 | "publishConfig": { 15 | "access": "public" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tuture-monorepo", 3 | "version": "3.0.0-alpha10", 4 | "private": true, 5 | "description": "Write tutorials from the future, with the power of Git and community.", 6 | "workspaces": [ 7 | "packages/*" 8 | ], 9 | "keywords": [ 10 | "learn-by-doing", 11 | "learn-by-examples", 12 | "hands-on", 13 | "tutorial", 14 | "diff-viewer", 15 | "git" 16 | ], 17 | "engines": { 18 | "node": ">=8.0.0" 19 | }, 20 | "scripts": { 21 | "lint": "eslint \"**/*.{js,jsx,ts,tsx}\" --fix", 22 | "test": "jest", 23 | "test:watch": "jest --watch", 24 | "build": "rollup -c", 25 | "build:watch": "rollup -c -w", 26 | "build:cli": "cd packages/cli && yarn && yarn build", 27 | "build:editor": "cd packages/editor && yarn && yarn build", 28 | "collect-editor": "cd packages/local-server && yarn collect-editor", 29 | "clean": "rimraf packages/**/dist && rimraf packages/cli/lib && rimraf packages/editor/build", 30 | "postinstall": "yarn build", 31 | "prerelease": "yarn clean && yarn build && yarn build:cli && yarn build:editor && yarn collect-editor", 32 | "release": "lerna publish" 33 | }, 34 | "repository": { 35 | "type": "git", 36 | "url": "https://github.com/tuture-dev/tuture.git" 37 | }, 38 | "license": "MIT", 39 | "bugs": { 40 | "url": "https://github.com/tuture-dev/tuture/issues" 41 | }, 42 | "homepage": "https://github.com/tuture-dev/tuture#readme", 43 | "husky": { 44 | "hooks": { 45 | "pre-commit": "lint-staged" 46 | } 47 | }, 48 | "lint-staged": { 49 | "*": [ 50 | "pretty-quick --staged", 51 | "eslint . --fix" 52 | ] 53 | }, 54 | "devDependencies": { 55 | "@rollup/plugin-node-resolve": "^7.1.1", 56 | "@types/faker": "^4.1.10", 57 | "@types/jest": "^23.3.1", 58 | "@types/lodash.clonedeep": "^4.5.6", 59 | "@types/rc": "^1.1.0", 60 | "@types/tmp": "^0.0.33", 61 | "@types/which": "^1.3.1", 62 | "@typescript-eslint/eslint-plugin": "^2.19.2", 63 | "@typescript-eslint/parser": "^2.19.2", 64 | "eslint": "^6.8.0", 65 | "eslint-config-airbnb": "^18.0.1", 66 | "eslint-config-prettier": "^6.10.0", 67 | "eslint-config-react-app": "^5.2.0", 68 | "eslint-plugin-emotion": "^10.0.27", 69 | "eslint-plugin-flowtype": "^4.6.0", 70 | "eslint-plugin-html": "^6.0.0", 71 | "eslint-plugin-import": "^2.20.1", 72 | "eslint-plugin-jsx-a11y": "^6.2.3", 73 | "eslint-plugin-prettier": "^3.1.2", 74 | "eslint-plugin-react": "^7.18.3", 75 | "eslint-plugin-react-hooks": "^1.7.0", 76 | "faker": "^4.1.0", 77 | "husky": "^4.2.3", 78 | "jest": "^24.9.0", 79 | "lerna": "^3.20.2", 80 | "lint-staged": "^10.0.7", 81 | "lodash.clonedeep": "^4.5.0", 82 | "prettier": "^1.19.1", 83 | "pretty-quick": "^2.0.1", 84 | "rimraf": "^2.6.2", 85 | "rollup": "^1.31.0", 86 | "rollup-plugin-babel": "^4.3.3", 87 | "rollup-plugin-commonjs": "^10.1.0", 88 | "rollup-plugin-css-only": "^2.0.0", 89 | "rollup-plugin-json": "^4.0.0", 90 | "rollup-plugin-jsx": "^1.0.3", 91 | "rollup-plugin-typescript2": "^0.26.0", 92 | "ts-jest": "^24.1.0", 93 | "typescript": "^3.8.3" 94 | }, 95 | "jest": { 96 | "transform": { 97 | "^.+\\.tsx?$": "ts-jest" 98 | }, 99 | "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$", 100 | "testPathIgnorePatterns": [ 101 | "test/*" 102 | ], 103 | "moduleFileExtensions": [ 104 | "ts", 105 | "tsx", 106 | "js" 107 | ], 108 | "collectCoverageFrom": [ 109 | "build/**/*.ts" 110 | ], 111 | "testURL": "http://localhost/", 112 | "verbose": true 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /packages/cli/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.md] 11 | trim_trailing_whitespace = false 12 | -------------------------------------------------------------------------------- /packages/cli/.gitignore: -------------------------------------------------------------------------------- 1 | *-debug.log 2 | *-error.log 3 | /.nyc_output 4 | /dist 5 | /lib 6 | /package-lock.json 7 | /tmp 8 | node_modules 9 | -------------------------------------------------------------------------------- /packages/cli/README.md: -------------------------------------------------------------------------------- 1 | @tuture/cli 2 | =========== 3 | 4 | CLI for Tuture 5 | 6 | [![oclif](https://img.shields.io/badge/cli-oclif-brightgreen.svg)](https://oclif.io) 7 | [![Version](https://img.shields.io/npm/v/@tuture/cli.svg)](https://npmjs.org/package/@tuture/cli) 8 | [![Downloads/week](https://img.shields.io/npm/dw/@tuture/cli.svg)](https://npmjs.org/package/@tuture/cli) 9 | [![License](https://img.shields.io/npm/l/@tuture/cli.svg)](https://github.com//cli/blob/master/package.json) 10 | 11 | 12 | * [Usage](#usage) 13 | * [Commands](#commands) 14 | 15 | # Usage 16 | 17 | ```sh-session 18 | $ npm install -g @tuture/cli 19 | $ tuture COMMAND 20 | running command... 21 | $ tuture (-v|--version|version) 22 | @tuture/cli/0.0.2 darwin-x64 node-v12.10.0 23 | $ tuture --help [COMMAND] 24 | USAGE 25 | $ tuture COMMAND 26 | ... 27 | ``` 28 | 29 | # Commands 30 | 31 | * [`tuture build`](#tuture-build) 32 | * [`tuture commit`](#tuture-commit) 33 | * [`tuture destroy`](#tuture-destroy) 34 | * [`tuture help [COMMAND]`](#tuture-help-command) 35 | * [`tuture init`](#tuture-init) 36 | * [`tuture pull`](#tuture-pull) 37 | * [`tuture push`](#tuture-push) 38 | * [`tuture reload`](#tuture-reload) 39 | * [`tuture sync`](#tuture-sync) 40 | * [`tuture up`](#tuture-up) 41 | 42 | ## `tuture build` 43 | 44 | Build tutorial into a markdown document 45 | 46 | ``` 47 | USAGE 48 | $ tuture build 49 | 50 | OPTIONS 51 | -h, --help show CLI help 52 | -o, --out=out name of output directory 53 | --hexo hexo compatibility mode 54 | ``` 55 | 56 | ## `tuture commit` 57 | 58 | Commit your tutorial to VCS (Git) 59 | 60 | ``` 61 | USAGE 62 | $ tuture commit 63 | 64 | OPTIONS 65 | -h, --help show CLI help 66 | -m, --message=message commit message 67 | ``` 68 | 69 | ## `tuture destroy` 70 | 71 | Delete all tuture files 72 | 73 | ``` 74 | USAGE 75 | $ tuture destroy 76 | 77 | OPTIONS 78 | -f, --force destroy without confirmation 79 | -h, --help show CLI help 80 | ``` 81 | 82 | ## `tuture help [COMMAND]` 83 | 84 | display help for tuture 85 | 86 | ``` 87 | USAGE 88 | $ tuture help [COMMAND] 89 | 90 | ARGUMENTS 91 | COMMAND command to show help for 92 | 93 | OPTIONS 94 | --all see all commands in CLI 95 | ``` 96 | 97 | _See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/v2.2.3/src/commands/help.ts)_ 98 | 99 | ## `tuture init` 100 | 101 | Initialize a tuture tutorial 102 | 103 | ``` 104 | USAGE 105 | $ tuture init 106 | 107 | OPTIONS 108 | -h, --help show CLI help 109 | -y, --yes do not ask for prompts 110 | ``` 111 | 112 | ## `tuture pull` 113 | 114 | Pull the remote tuture branch to local 115 | 116 | ``` 117 | USAGE 118 | $ tuture pull 119 | 120 | OPTIONS 121 | -h, --help show CLI help 122 | -r, --remote=remote name of remote to pull 123 | ``` 124 | 125 | ## `tuture push` 126 | 127 | Push the tuture branch to remote 128 | 129 | ``` 130 | USAGE 131 | $ tuture push 132 | 133 | OPTIONS 134 | -h, --help show CLI help 135 | -r, --remote=remote name of remote to push 136 | ``` 137 | 138 | ## `tuture reload` 139 | 140 | Update workspace with latest commit history 141 | 142 | ``` 143 | USAGE 144 | $ tuture reload 145 | 146 | OPTIONS 147 | -h, --help show CLI help 148 | ``` 149 | 150 | ## `tuture sync` 151 | 152 | Synchronize workspace with local/remote branch 153 | 154 | ``` 155 | USAGE 156 | $ tuture sync 157 | 158 | OPTIONS 159 | -h, --help show CLI help 160 | -m, --message=message commit message 161 | --configureRemotes configure remotes before synchronization 162 | --continue continue synchronization after resolving conflicts 163 | --noPull do not pull from remote 164 | --noPush do not push to remote 165 | ``` 166 | 167 | ## `tuture up` 168 | 169 | Render and edit tutorial in browser 170 | 171 | ``` 172 | USAGE 173 | $ tuture up 174 | 175 | OPTIONS 176 | -h, --help show CLI help 177 | -p, --port=port which port to use for editor server 178 | ``` 179 | 180 | -------------------------------------------------------------------------------- /packages/cli/bin/run: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require('@oclif/command').run() 4 | .then(require('@oclif/command/flush')) 5 | .catch(require('@oclif/errors/handle')) 6 | -------------------------------------------------------------------------------- /packages/cli/bin/run.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | node "%~dp0\run" %* 4 | -------------------------------------------------------------------------------- /packages/cli/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@tuture/cli", 3 | "description": "Command line interface for Tuture", 4 | "version": "0.0.7", 5 | "bin": { 6 | "tuture": "./bin/run" 7 | }, 8 | "bugs": "https://github.com/tuture-dev/tuture/issues", 9 | "scripts": { 10 | "build": "rimraf lib && tsc -b", 11 | "postpack": "rimraf oclif.manifest.json", 12 | "prepack": "rimraf lib && tsc -b && oclif-dev manifest && oclif-dev readme", 13 | "test": "echo NO TESTS" 14 | }, 15 | "dependencies": { 16 | "@oclif/command": "^1", 17 | "@oclif/config": "^1", 18 | "@oclif/plugin-help": "^2", 19 | "@tuture/core": "^0.0.7", 20 | "@tuture/local-server": "^0.0.7", 21 | "chalk": "^2.4.2", 22 | "editure": "^0.1.2", 23 | "editure-constants": "^0.1.2", 24 | "fs-extra": "^7.0.0", 25 | "get-port": "^5.1.1", 26 | "inquirer": "^6.0.0", 27 | "micromatch": "^3.1.10", 28 | "open": "^6.4.0", 29 | "parse-diff": "^0.4.2", 30 | "rc": "^1.2.8", 31 | "simple-git": "^1.129.0", 32 | "tslib": "^1", 33 | "tuture-slate": "0.57.4", 34 | "which": "^1.3.1", 35 | "winston": "^3.1.0", 36 | "yutang": "^0.0.4" 37 | }, 38 | "devDependencies": { 39 | "@oclif/dev-cli": "^1", 40 | "@types/inquirer": "^6.5.0", 41 | "@types/micromatch": "^4.0.1", 42 | "@types/node": "^10", 43 | "globby": "^10", 44 | "rimraf": "^2.6.2", 45 | "ts-node": "^8", 46 | "typescript": "^3.3" 47 | }, 48 | "engines": { 49 | "node": ">=8.0.0" 50 | }, 51 | "files": [ 52 | "/bin", 53 | "/lib", 54 | "/npm-shrinkwrap.json", 55 | "/oclif.manifest.json" 56 | ], 57 | "keywords": [ 58 | "tuture", 59 | "cli", 60 | "oclif" 61 | ], 62 | "license": "MIT", 63 | "main": "lib/index.js", 64 | "oclif": { 65 | "commands": "./lib/commands", 66 | "bin": "tuture", 67 | "plugins": [ 68 | "@oclif/plugin-help" 69 | ] 70 | }, 71 | "types": "lib/index.d.ts" 72 | } 73 | -------------------------------------------------------------------------------- /packages/cli/src/base.ts: -------------------------------------------------------------------------------- 1 | import { Command } from '@oclif/command'; 2 | import fs from 'fs-extra'; 3 | import rc from 'rc'; 4 | import { TUTURE_ROOT, TUTURE_IGNORE_PATH } from '@tuture/core'; 5 | 6 | import defaultConfig from './config'; 7 | import logger from './utils/logger'; 8 | import { checkInitStatus } from './utils'; 9 | import { EXIT_CODE } from './constants'; 10 | import { git } from './utils/git'; 11 | 12 | export default abstract class BaseCommand extends Command { 13 | // User configurations. 14 | userConfig: any = defaultConfig; 15 | 16 | // The branch which the user is working upon. 17 | currentBranch: string = 'master'; 18 | 19 | async init() { 20 | this.userConfig = rc('tuture', defaultConfig); 21 | 22 | // Check initialization status when running commands except `init`. 23 | if (this.id !== 'init') { 24 | try { 25 | await checkInitStatus(); 26 | } catch (err) { 27 | logger.log('error', err.message); 28 | this.exit(EXIT_CODE.NOT_INIT); 29 | } 30 | } 31 | 32 | if (!fs.existsSync(TUTURE_ROOT)) { 33 | fs.mkdirSync(TUTURE_ROOT); 34 | } 35 | 36 | if (fs.existsSync(TUTURE_IGNORE_PATH)) { 37 | const patterns = this.userConfig.ignoredFiles.concat( 38 | fs 39 | .readFileSync(TUTURE_IGNORE_PATH) 40 | .toString() 41 | .split('\n') 42 | .filter((pattern) => !pattern.match(/#/) && pattern.match(/\b/)), 43 | ); 44 | this.userConfig.ignoredFiles = patterns; 45 | } 46 | 47 | if (await git.checkIsRepo()) { 48 | // Record the current branch. 49 | const status = await git.status(); 50 | this.currentBranch = status.current; 51 | } 52 | } 53 | 54 | async finally() { 55 | // Clean tuture root if it's empty, since it's created for no reason.. 56 | if ( 57 | fs.existsSync(TUTURE_ROOT) && 58 | fs.readdirSync(TUTURE_ROOT).length === 0 59 | ) { 60 | fs.removeSync(TUTURE_ROOT); 61 | } 62 | 63 | if (await git.checkIsRepo()) { 64 | try { 65 | // Ensure we are back to original branch. 66 | await git.checkout(['-q', this.currentBranch]); 67 | } catch { 68 | // Just silently failed. 69 | } 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /packages/cli/src/commands/commit.ts: -------------------------------------------------------------------------------- 1 | import cp from 'child_process'; 2 | import fs from 'fs-extra'; 3 | import { flags } from '@oclif/command'; 4 | import { prompt } from 'inquirer'; 5 | import { 6 | collectionPath, 7 | collectionVcsPath, 8 | saveCheckpoint, 9 | collectionCheckpoint, 10 | } from '@tuture/local-server'; 11 | import { TUTURE_BRANCH, COLLECTION_PATH, ASSETS_JSON_PATH } from '@tuture/core'; 12 | 13 | import BaseCommand from '../base'; 14 | import logger from '../utils/logger'; 15 | import { git } from '../utils/git'; 16 | import { initializeTutureBranch } from '../utils/collection'; 17 | 18 | export default class Commit extends BaseCommand { 19 | static description = 'Commit your tutorial to VCS (Git)'; 20 | 21 | static flags = { 22 | help: flags.help({ char: 'h' }), 23 | message: flags.string({ 24 | char: 'm', 25 | description: 'commit message', 26 | }), 27 | }; 28 | 29 | async run() { 30 | const { flags } = this.parse(Commit); 31 | this.userConfig = Object.assign(this.userConfig, flags); 32 | 33 | const message = 34 | flags.message || 35 | ( 36 | await prompt<{ message: string }>([ 37 | { 38 | name: 'message', 39 | type: 'input', 40 | default: `Commit on ${new Date()}`, 41 | }, 42 | ]) 43 | ).message; 44 | 45 | await initializeTutureBranch(); 46 | 47 | // Checkout tuture branch and add tuture.yml. 48 | await git.checkout(TUTURE_BRANCH); 49 | 50 | // Trying to copy and add collection data to staging. 51 | fs.copySync(collectionPath, collectionVcsPath); 52 | await git.add(collectionVcsPath); 53 | 54 | // COMPAT: remove collection.json and tuture-assets.json from project root. 55 | if (fs.existsSync(COLLECTION_PATH)) { 56 | await git.rm(COLLECTION_PATH); 57 | } 58 | if (fs.existsSync(ASSETS_JSON_PATH)) { 59 | await git.rm(ASSETS_JSON_PATH); 60 | } 61 | 62 | fs.removeSync(collectionCheckpoint); 63 | 64 | // Commit changes to tuture branch. 65 | cp.execSync(`git commit --allow-empty -m "tuture: ${message}"`); 66 | logger.log('success', `Committed to branch ${TUTURE_BRANCH} (${message})`); 67 | 68 | // Copy the last committed file. 69 | await saveCheckpoint(); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /packages/cli/src/commands/destroy.ts: -------------------------------------------------------------------------------- 1 | import { flags } from '@oclif/command'; 2 | import { prompt } from 'inquirer'; 3 | import { TUTURE_BRANCH } from '@tuture/core'; 4 | 5 | import BaseCommand from '../base'; 6 | import logger from '../utils/logger'; 7 | import { git } from '../utils/git'; 8 | import { removeTutureSuite } from '../utils'; 9 | 10 | export default class Destroy extends BaseCommand { 11 | static description = 'Delete all tuture files'; 12 | 13 | static flags = { 14 | help: flags.help({ char: 'h' }), 15 | force: flags.boolean({ 16 | char: 'f', 17 | description: 'destroy without confirmation', 18 | }), 19 | }; 20 | 21 | async promptConfirm(message: string, defaultChoice = false) { 22 | const response = await prompt<{ answer: boolean }>([ 23 | { 24 | type: 'confirm', 25 | name: 'answer', 26 | message, 27 | default: defaultChoice, 28 | }, 29 | ]); 30 | 31 | return response.answer; 32 | } 33 | 34 | async run() { 35 | const { flags } = this.parse(Destroy); 36 | 37 | if (!flags.force) { 38 | const confirmed = await this.promptConfirm('Are you sure?'); 39 | if (!confirmed) { 40 | this.exit(0); 41 | } 42 | } 43 | 44 | await removeTutureSuite(); 45 | 46 | const { all: allBranches } = await git.branch({ '-a': true }); 47 | 48 | // Remove local tuture branch if exists. 49 | if (allBranches.includes(TUTURE_BRANCH)) { 50 | await git.branch(['-D', TUTURE_BRANCH]); 51 | logger.log('success', 'Local tuture branch has been deleted.'); 52 | } 53 | 54 | const remoteBranches = allBranches.filter( 55 | (branch) => branch.indexOf(TUTURE_BRANCH) > 0, 56 | ); 57 | 58 | if (remoteBranches.length > 0) { 59 | const confirmed = await this.promptConfirm( 60 | `Do you want to delete ${remoteBranches.length} remote tuture branch(es)?`, 61 | ); 62 | 63 | if (confirmed) { 64 | try { 65 | // Delete all remote branches. 66 | await Promise.all( 67 | remoteBranches.map(async (branch) => { 68 | const [_, remote, ref] = branch.split('/'); 69 | await git.push(remote, ref, { '-d': true }); 70 | logger.log('success', `${branch} has been deleted.`); 71 | }), 72 | ); 73 | } catch (err) { 74 | logger.log('error', err.message); 75 | } 76 | } 77 | } 78 | 79 | logger.log('success', 'Tuture tutorial has been destroyed!'); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /packages/cli/src/commands/init.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import fs from 'fs-extra'; 3 | import { flags } from '@oclif/command'; 4 | import { prompt } from 'inquirer'; 5 | import { Collection, SCHEMA_VERSION, randHex } from '@tuture/core'; 6 | import { collectionPath, saveCollection } from '@tuture/local-server'; 7 | 8 | import logger from '../utils/logger'; 9 | import BaseCommand from '../base'; 10 | import { makeSteps, removeTutureSuite, selectRemotes } from '../utils'; 11 | import { git, inferGithubField, appendGitignore } from '../utils/git'; 12 | 13 | export default class Init extends BaseCommand { 14 | static description = 'Initialize a tuture tutorial'; 15 | 16 | static flags = { 17 | help: flags.help({ char: 'h' }), 18 | yes: flags.boolean({ 19 | char: 'y', 20 | description: 'do not ask for prompts', 21 | }), 22 | }; 23 | 24 | async promptInitGit(yes: boolean) { 25 | const response = yes 26 | ? { answer: true } 27 | : await prompt<{ 28 | answer: boolean; 29 | }>([ 30 | { 31 | name: 'answer', 32 | type: 'confirm', 33 | message: 34 | 'You are not in a Git repository, do you want to initialize one?', 35 | default: false, 36 | }, 37 | ]); 38 | 39 | if (!response.answer) { 40 | this.exit(0); 41 | } else { 42 | await git.init(); 43 | logger.log('success', 'Git repository is initialized!'); 44 | } 45 | } 46 | 47 | async promptMetaData(yes: boolean) { 48 | const answer = yes 49 | ? { name: 'My Awesome Tutorial' } 50 | : await prompt<{ name: string; description: string }>([ 51 | { 52 | name: 'name', 53 | message: 'Collection Name', 54 | default: 'My Awesome Tutorial', 55 | }, 56 | { 57 | name: 'description', 58 | message: 'Description', 59 | }, 60 | ]); 61 | 62 | return answer; 63 | } 64 | 65 | async run() { 66 | const { flags } = this.parse(Init); 67 | 68 | if (fs.existsSync(collectionPath)) { 69 | logger.log('success', 'Tuture tutorial has already been initialized!'); 70 | this.exit(0); 71 | } 72 | 73 | if (!(await git.checkIsRepo())) { 74 | await this.promptInitGit(flags.yes); 75 | } 76 | 77 | const meta = await this.promptMetaData(flags.yes); 78 | 79 | try { 80 | const steps = await makeSteps(this.userConfig.ignoredFiles); 81 | const defaultArticleId = randHex(8); 82 | 83 | steps.forEach((step) => { 84 | step.articleId = defaultArticleId; 85 | }); 86 | 87 | const collection: Collection = { 88 | ...meta, 89 | id: randHex(32), 90 | created: new Date(), 91 | articles: [ 92 | { 93 | id: defaultArticleId, 94 | name: meta.name, 95 | description: '', 96 | topics: [], 97 | categories: [], 98 | created: new Date(), 99 | cover: '', 100 | }, 101 | ], 102 | steps, 103 | }; 104 | 105 | const github = await inferGithubField(); 106 | if (github) { 107 | logger.log( 108 | 'info', 109 | `Inferred github repository: ${chalk.underline( 110 | github, 111 | )}. Feel free to revise or delete it.`, 112 | ); 113 | collection.github = github; 114 | } 115 | 116 | const remotes = await git.getRemotes(true); 117 | 118 | if (remotes.length > 0) { 119 | if (flags.yes) { 120 | collection.remotes = [remotes[0]]; 121 | } else { 122 | collection.remotes = await selectRemotes(remotes); 123 | } 124 | } 125 | 126 | collection.version = SCHEMA_VERSION; 127 | 128 | saveCollection(collection); 129 | appendGitignore(); 130 | 131 | logger.log('success', 'Tuture tutorial has been initialized!'); 132 | } catch (err) { 133 | await removeTutureSuite(); 134 | logger.log({ 135 | level: 'error', 136 | message: err.message, 137 | error: err, 138 | }); 139 | this.exit(1); 140 | } 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /packages/cli/src/commands/pull.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import { flags } from '@oclif/command'; 3 | import { TUTURE_BRANCH } from '@tuture/core'; 4 | 5 | import BaseCommand from '../base'; 6 | import logger from '../utils/logger'; 7 | import { git } from '../utils/git'; 8 | 9 | export default class Pull extends BaseCommand { 10 | static description = 'Pull the remote tuture branch to local'; 11 | 12 | static flags = { 13 | help: flags.help({ char: 'h' }), 14 | remote: flags.string({ char: 'r', description: 'name of remote to pull' }), 15 | }; 16 | 17 | async run() { 18 | const { flags } = this.parse(Pull); 19 | this.userConfig = Object.assign(this.userConfig, flags); 20 | 21 | let remoteToPull = flags.remote; 22 | if (!remoteToPull) { 23 | const remotes = await git.getRemotes(true); 24 | 25 | if (remotes.length === 0) { 26 | logger.log('error', 'Remote repository has not been configured.'); 27 | this.exit(1); 28 | } else { 29 | // Select the first remote by default. 30 | remoteToPull = remotes[0].name; 31 | } 32 | } 33 | 34 | try { 35 | await git.checkout(TUTURE_BRANCH); 36 | 37 | logger.log('info', `Starting to pull from ${remoteToPull}.`); 38 | const { files } = await git.pull(remoteToPull, TUTURE_BRANCH); 39 | 40 | if (files.length > 0) { 41 | // Commit changes to tuture branch. 42 | logger.log('success', `Pulled from ${remoteToPull} successfully.`); 43 | } else { 44 | logger.log('success', `Already up-to-date with ${remoteToPull}.`); 45 | } 46 | } catch (err) { 47 | const { conflicted } = await git.status(); 48 | if (conflicted.length > 0) { 49 | logger.log( 50 | 'error', 51 | `Please manually resolve the conflict and run ${chalk.bold( 52 | 'tuture sync --continue', 53 | )} to move on.`, 54 | ); 55 | } else { 56 | // No remote tuture branch. 57 | logger.log('error', String(err.message).trim()); 58 | } 59 | 60 | this.exit(1); 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /packages/cli/src/commands/push.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import fs from 'fs-extra'; 3 | import { flags } from '@oclif/command'; 4 | import { TUTURE_BRANCH } from '@tuture/core'; 5 | import { collectionPath } from '@tuture/local-server'; 6 | 7 | import BaseCommand from '../base'; 8 | import logger from '../utils/logger'; 9 | import { git } from '../utils/git'; 10 | import { initializeTutureBranch } from '../utils/collection'; 11 | 12 | export default class Push extends BaseCommand { 13 | static description = 'Push the tuture branch to remote'; 14 | 15 | static flags = { 16 | help: flags.help({ char: 'h' }), 17 | remote: flags.string({ char: 'r', description: 'name of remote to push' }), 18 | }; 19 | 20 | async run() { 21 | const { flags } = this.parse(Push); 22 | this.userConfig = Object.assign(this.userConfig, flags); 23 | 24 | let remoteToPush = flags.remote; 25 | if (!remoteToPush) { 26 | const remotes = await git.getRemotes(true); 27 | 28 | if (remotes.length === 0) { 29 | logger.log('error', 'Remote repository has not been configured.'); 30 | this.exit(1); 31 | } else { 32 | // Select the first remote by default. 33 | remoteToPush = remotes[0].name; 34 | } 35 | } 36 | 37 | await initializeTutureBranch(); 38 | 39 | try { 40 | // Checkout tuture branch and add tuture.yml. 41 | await git.checkout(TUTURE_BRANCH); 42 | 43 | if (!fs.existsSync(collectionPath)) { 44 | logger.log( 45 | 'error', 46 | `Cannot push empty tuture branch. Please commit your tutorial with ${chalk.bold( 47 | 'tuture commit', 48 | )}.`, 49 | ); 50 | this.exit(1); 51 | } 52 | 53 | logger.log('info', `Starting to push to ${remoteToPush}.`); 54 | 55 | await git.push(remoteToPush, TUTURE_BRANCH); 56 | logger.log('success', `Pushed to ${remoteToPush} successfully.`); 57 | } catch (err) { 58 | logger.log('error', String(err.message).trim()); 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /packages/cli/src/commands/reload.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra'; 2 | import mm from 'micromatch'; 3 | import { flags } from '@oclif/command'; 4 | import { Step, getStepTitle } from '@tuture/core'; 5 | import { 6 | loadCollection, 7 | collectionPath, 8 | saveCollection, 9 | } from '@tuture/local-server'; 10 | 11 | import sync from './sync'; 12 | import BaseCommand from '../base'; 13 | import { git } from '../utils/git'; 14 | import logger from '../utils/logger'; 15 | import { makeSteps, mergeSteps } from '../utils'; 16 | 17 | export default class Reload extends BaseCommand { 18 | static description = 'Update workspace with latest commit history'; 19 | 20 | static flags = { 21 | help: flags.help({ char: 'h' }), 22 | }; 23 | 24 | async run() { 25 | this.parse(Reload); 26 | 27 | // Run sync command if workspace is not created. 28 | if (!fs.existsSync(collectionPath)) { 29 | await sync.run([]); 30 | } 31 | 32 | const collection = loadCollection(); 33 | 34 | // Checkout master branch and add tuture.yml. 35 | await git.checkout('master'); 36 | 37 | const ignoredFiles: string[] = this.userConfig.ignoredFiles; 38 | const currentSteps: Step[] = await makeSteps(ignoredFiles); 39 | const lastArticleId = collection.articles.slice(-1)[0].id; 40 | 41 | currentSteps.forEach((step) => { 42 | if (!collection.steps.map((step) => step.commit).includes(step.commit)) { 43 | logger.log( 44 | 'success', 45 | `New step: ${getStepTitle(step)} (${step.commit})`, 46 | ); 47 | 48 | // For newly added steps, assign it to the last article. 49 | step.articleId = lastArticleId; 50 | } 51 | }); 52 | 53 | collection.steps = mergeSteps(collection.steps, currentSteps); 54 | 55 | collection.steps.forEach((step) => { 56 | if (step.outdated) { 57 | logger.log( 58 | 'warning', 59 | `Outdated step: ${getStepTitle(step)} (${step.commit})`, 60 | ); 61 | } 62 | 63 | // Set display to false for ignored files. 64 | step.children.forEach((child) => { 65 | if (child.type === 'file') { 66 | const diff = child.children[1]; 67 | if (ignoredFiles.some((pattern) => mm.isMatch(diff.file, pattern))) { 68 | child.display = false; 69 | } 70 | } 71 | }); 72 | }); 73 | 74 | saveCollection(collection); 75 | 76 | logger.log('success', 'Reload complete!'); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /packages/cli/src/commands/up.ts: -------------------------------------------------------------------------------- 1 | import open from 'open'; 2 | import fs from 'fs-extra'; 3 | import getPort from 'get-port'; 4 | import { flags } from '@oclif/command'; 5 | import { 6 | makeServer, 7 | loadCollection, 8 | collectionPath, 9 | } from '@tuture/local-server'; 10 | 11 | import reload from './reload'; 12 | import BaseCommand from '../base'; 13 | import logger from '../utils/logger'; 14 | import { diffPath, shouldReloadSteps } from '../utils/git'; 15 | 16 | export default class Up extends BaseCommand { 17 | static description = 'Render and edit tutorial in browser'; 18 | 19 | static flags = { 20 | help: flags.help({ char: 'h' }), 21 | port: flags.integer({ 22 | char: 'p', 23 | description: 'which port to use for editor server', 24 | }), 25 | }; 26 | 27 | async fireTutureServer() { 28 | const port = await getPort({ port: this.userConfig.port }); 29 | const server = makeServer({ 30 | baseUrl: '/api', 31 | onGitHistoryChange: () => { 32 | reload.run([]); 33 | }, 34 | }); 35 | 36 | server.listen(port, () => { 37 | const url = `http://localhost:${port}`; 38 | logger.log('success', `Tutorial editor is served on ${url}`); 39 | 40 | // Don't open browser in test environment. 41 | if (process.env.TEST !== 'yes') { 42 | open(url); 43 | } 44 | }); 45 | } 46 | 47 | async run() { 48 | const { flags } = this.parse(Up); 49 | this.userConfig = Object.assign(this.userConfig, flags); 50 | 51 | // Run sync command if workspace is not prepared. 52 | if ( 53 | !fs.existsSync(collectionPath) || 54 | !fs.existsSync(diffPath) || 55 | (await shouldReloadSteps()) 56 | ) { 57 | await reload.run([]); 58 | } 59 | 60 | // Trying to load collection for sanity check. 61 | loadCollection(); 62 | 63 | this.fireTutureServer(); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /packages/cli/src/config.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import { TUTURE_ROOT } from '@tuture/core'; 3 | 4 | export default { 5 | // Directory to store assets temporarily. 6 | assetsRoot: path.join(TUTURE_ROOT, 'assets'), 7 | 8 | // Time interval to synchronize all assets. 9 | assetsSyncInterval: 10000, 10 | 11 | // Path to build outputs. 12 | buildPath: path.join(TUTURE_ROOT, 'build'), 13 | 14 | // Port to use for tuture-server. 15 | port: 3013, 16 | 17 | // Files that should be commited but won't be tracked by Tuture. 18 | ignoredFiles: [ 19 | // Git-related files 20 | '.gitignore', 21 | '.gitattributes', 22 | 23 | // Tuture-related files 24 | '.tuturerc', 25 | '.tutureignore', 26 | ], 27 | }; 28 | -------------------------------------------------------------------------------- /packages/cli/src/constants.ts: -------------------------------------------------------------------------------- 1 | export enum EXIT_CODE { 2 | NOT_INIT = 1, 3 | NO_STAGE, 4 | NO_REMOTE, 5 | CONFLICT, 6 | } 7 | -------------------------------------------------------------------------------- /packages/cli/src/index.ts: -------------------------------------------------------------------------------- 1 | export { run } from '@oclif/command'; 2 | -------------------------------------------------------------------------------- /packages/cli/src/utils/collection.ts: -------------------------------------------------------------------------------- 1 | import logger from './logger'; 2 | import { git } from './git'; 3 | import { TUTURE_BRANCH } from '@tuture/core'; 4 | 5 | /** 6 | * Whether the local tuture branch exists. 7 | */ 8 | export async function hasLocalTutureBranch() { 9 | return (await git.branchLocal()).all.includes(TUTURE_BRANCH); 10 | } 11 | 12 | /** 13 | * Whether the remote tuture branch exists. 14 | */ 15 | export async function hasRemoteTutureBranch() { 16 | const remote = await git.remote([]); 17 | 18 | if (!remote) { 19 | logger.log('warning', 'No remote found for this repository.'); 20 | return false; 21 | } 22 | 23 | const branchExists = async (branch: string) => { 24 | const { all } = await git.branch({ '-a': true }); 25 | return all.includes(branch); 26 | }; 27 | 28 | const remoteBranch = `remotes/${remote.trim()}/${TUTURE_BRANCH}`; 29 | 30 | // Trying to update remote branches (time-consuming). 31 | await git.remote(['update', '--prune']); 32 | 33 | return await branchExists(remoteBranch); 34 | } 35 | 36 | /** 37 | * Fetch tuture branch from remote. 38 | */ 39 | export async function initializeTutureBranch() { 40 | const { all: allBranches } = await git.branch({ '-a': true }); 41 | 42 | // Already exists. 43 | if (allBranches.includes(TUTURE_BRANCH)) { 44 | return; 45 | } 46 | 47 | const remoteBranchIndex = allBranches 48 | .map((branch) => branch.split('/').slice(-1)[0]) 49 | .indexOf(TUTURE_BRANCH); 50 | 51 | if (remoteBranchIndex < 0) { 52 | await git.branch([TUTURE_BRANCH]); 53 | logger.log('info', 'New tuture branch has been created.'); 54 | } else { 55 | const [_, remote, branch] = allBranches[remoteBranchIndex].split('/'); 56 | await git.fetch(remote, branch); 57 | logger.log('success', 'Remote tuture branch has been fetched.'); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /packages/cli/src/utils/git.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra'; 2 | import path from 'path'; 3 | import parseDiff from 'parse-diff'; 4 | import simplegit from 'simple-git/promise'; 5 | import { RawDiff, TUTURE_ROOT, DIFF_PATH, isCommitEqual } from '@tuture/core'; 6 | import { loadCollection } from '@tuture/local-server'; 7 | 8 | import logger from './logger'; 9 | 10 | // Interface for running git commands. 11 | // https://github.com/steveukx/git-js 12 | export const git = simplegit().silent(true); 13 | 14 | export const diffPath = path.join( 15 | process.env.TUTURE_PATH || process.cwd(), 16 | TUTURE_ROOT, 17 | DIFF_PATH, 18 | ); 19 | 20 | /** 21 | * Store diff of all commits. 22 | */ 23 | export async function storeDiff(commits: string[]) { 24 | const diffPromises = commits.map(async (commit: string) => { 25 | const command = ['show', '-U99999', commit]; 26 | const output = await git.raw(command); 27 | const diffText = output 28 | .replace(/\\ No newline at end of file\n/g, '') 29 | .split('\n\n') 30 | .slice(-1)[0]; 31 | const diff = parseDiff(diffText); 32 | return { commit, diff } as RawDiff; 33 | }); 34 | 35 | const diffs = await Promise.all(diffPromises); 36 | 37 | fs.writeFileSync(diffPath, JSON.stringify(diffs)); 38 | 39 | return diffs; 40 | } 41 | 42 | /** 43 | * Append .tuture rule to gitignore. 44 | * If it's already ignored, do nothing. 45 | * If .gitignore doesn't exist, create one and add the rule. 46 | */ 47 | export function appendGitignore() { 48 | if (!fs.existsSync('.gitignore')) { 49 | fs.writeFileSync('.gitignore', `${TUTURE_ROOT}\n`); 50 | logger.log('info', '.gitignore file created.'); 51 | } else if ( 52 | !fs 53 | .readFileSync('.gitignore') 54 | .toString() 55 | .includes(TUTURE_ROOT) 56 | ) { 57 | fs.appendFileSync('.gitignore', `\n${TUTURE_ROOT}`); 58 | logger.log('info', '.gitignore rules appended.'); 59 | } 60 | } 61 | 62 | /** 63 | * Infer github field from available information. 64 | */ 65 | export async function inferGithubField() { 66 | let github: string = ''; 67 | try { 68 | // Trying to infer github repo url from origin. 69 | const remote = await git.remote([]); 70 | if (remote) { 71 | const origin = await git.remote(['get-url', remote.trim()]); 72 | if (origin) { 73 | github = origin.replace('.git', '').trim(); 74 | } 75 | } 76 | } catch { 77 | // No remote url, infer github field from git username and cwd. 78 | let username = await git.raw(['config', '--get', 'user.name']); 79 | if (!username) { 80 | username = await git.raw(['config', '--global', '--get', 'user.name']); 81 | } 82 | 83 | if (username) { 84 | const { name: repoName } = path.parse(process.cwd()); 85 | github = `https://github.com/${username.trim()}/${repoName}`; 86 | } 87 | } 88 | 89 | return github; 90 | } 91 | 92 | /** 93 | * Determine whether we should run `reload` command. 94 | */ 95 | export async function shouldReloadSteps() { 96 | const { all } = await git.log(); 97 | const gitCommits = all 98 | .filter((log) => !log.message.startsWith('tuture:')) 99 | .map((log) => log.hash); 100 | 101 | const { steps } = loadCollection(); 102 | const collectionCommits = steps 103 | .filter((step) => !step.outdated) 104 | .map((step) => step.commit); 105 | 106 | collectionCommits.reverse(); 107 | 108 | let shouldReload = false; 109 | 110 | for ( 111 | let i = 0; 112 | i < Math.min(gitCommits.length, collectionCommits.length); 113 | i++ 114 | ) { 115 | if (!isCommitEqual(gitCommits[i], collectionCommits[i])) { 116 | shouldReload = true; 117 | break; 118 | } 119 | } 120 | 121 | return shouldReload; 122 | } 123 | -------------------------------------------------------------------------------- /packages/cli/src/utils/internals.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generate HTML code for user profile. 3 | */ 4 | export function generateUserProfile(github: string) { 5 | const matched = github.match(/github.com\/(.+)\/(.+)/); 6 | if (!matched) { 7 | return ''; 8 | } 9 | 10 | const user = matched[1]; 11 | const avatarUrl = `/images/avatars/${user}.jpg`; 12 | const homepageUrl = `https://github.com/${user}`; 13 | 14 | return `
15 |
16 | 17 |
18 |
19 |
20 |

@${user}

21 |
22 |
23 | 查看代码 24 |
25 |
26 |
`; 27 | } 28 | -------------------------------------------------------------------------------- /packages/cli/src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import winston, { format, createLogger } from 'winston'; 3 | 4 | const logLevels = { 5 | error: 0, 6 | warning: 1, 7 | success: 2, 8 | info: 3, 9 | }; 10 | 11 | const consoleFormat = format.printf((info) => { 12 | const { level, message } = info; 13 | switch (level) { 14 | case 'error': 15 | return `${chalk.red(level)} ${message}`; 16 | case 'warning': 17 | return `${chalk.yellow(level)} ${message}`; 18 | case 'success': 19 | return `${chalk.green(level)} ${message}`; 20 | case 'info': 21 | return `${chalk.blue(level)} ${message}`; 22 | default: 23 | return message; 24 | } 25 | }); 26 | 27 | const fileFormat = format.printf((info) => { 28 | const { message, error, timestamp } = info; 29 | let log = `${timestamp} ${message}\n`; 30 | if (error) { 31 | const { message, stack } = error as Error; 32 | log += `${message}\n${stack}\n`; 33 | } 34 | return log; 35 | }); 36 | 37 | const logger = createLogger({ 38 | levels: logLevels, 39 | transports: [ 40 | new winston.transports.Console({ level: 'info', format: consoleFormat }), 41 | ], 42 | }); 43 | 44 | export default logger; 45 | -------------------------------------------------------------------------------- /packages/cli/src/utils/nodes.ts: -------------------------------------------------------------------------------- 1 | import { Element } from 'tuture-slate'; 2 | import { PARAGRAPH } from 'editure-constants'; 3 | import { Explain } from '@tuture/core'; 4 | 5 | export function getEmptyChildren(): Element[] { 6 | return [{ type: PARAGRAPH, children: [{ text: '' }] }]; 7 | } 8 | 9 | export function getEmptyExplain(): Explain { 10 | return { 11 | type: 'explain', 12 | fixed: true, 13 | children: getEmptyChildren(), 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /packages/cli/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "allowSyntheticDefaultImports": true, 5 | "esModuleInterop": true, 6 | "forceConsistentCasingInFileNames": true, 7 | "importHelpers": true, 8 | "module": "commonjs", 9 | "outDir": "lib", 10 | "rootDir": "src", 11 | "strict": true, 12 | "target": "es6", 13 | "typeRoots": ["node_modules/@types", "../../node_modules/@types"] 14 | }, 15 | "include": ["src/**/*"] 16 | } 17 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@tuture/core", 3 | "description": "Core logics and data interfaces for Tuture", 4 | "version": "0.0.7", 5 | "main": "dist/index.js", 6 | "module": "dist/index.esm.js", 7 | "types": "dist/index.d.ts", 8 | "files": [ 9 | "dist/" 10 | ], 11 | "license": "MIT", 12 | "dependencies": { 13 | "editure": "^0.1.9", 14 | "lodash.omit": "^4.5.0", 15 | "tuture-slate": "^0.57.4" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/core/src/constants.ts: -------------------------------------------------------------------------------- 1 | export const COLLECTION_PATH = 'collection.json'; 2 | 3 | export const COLLECTION_CHECKPOINT = 'collection.ckpt.json'; 4 | 5 | export const ASSETS_JSON_PATH = 'tuture-assets.json'; 6 | 7 | export const TUTURE_IGNORE_PATH = '.tutureignore'; 8 | 9 | // The workspace for storing tutorial-related data. 10 | export const TUTURE_ROOT = '.tuture'; 11 | 12 | // Directory which houses tutorial data on VCS. 13 | export const TUTURE_VCS_ROOT = '.tuture-committed'; 14 | 15 | export const DIFF_PATH = 'diff.json'; 16 | 17 | export const TUTURE_COMMIT_PREFIX = 'tuture:'; 18 | 19 | // Branch for commiting tutorial content. 20 | export const TUTURE_BRANCH = 'tuture'; 21 | -------------------------------------------------------------------------------- /packages/core/src/diff.ts: -------------------------------------------------------------------------------- 1 | // Ported from https://github.com/sergeyt/parse-diff. 2 | 3 | export interface DiffFile { 4 | chunks: Chunk[]; 5 | deletions: number; 6 | additions: number; 7 | from?: string; 8 | to?: string; 9 | index?: string[]; 10 | deleted?: true; 11 | new?: true; 12 | } 13 | 14 | export interface Chunk { 15 | content: string; 16 | changes: Change[]; 17 | oldStart: number; 18 | oldLines: number; 19 | newStart: number; 20 | newLines: number; 21 | } 22 | 23 | export interface NormalChange { 24 | type: 'normal'; 25 | ln1: number; 26 | ln2: number; 27 | normal: true; 28 | content: string; 29 | } 30 | 31 | export interface AddChange { 32 | type: 'add'; 33 | add: true; 34 | ln: number; 35 | content: string; 36 | } 37 | 38 | export interface DeleteChange { 39 | type: 'del'; 40 | del: true; 41 | ln: number; 42 | content: string; 43 | } 44 | 45 | export type ChangeType = 'normal' | 'add' | 'del'; 46 | 47 | export type Change = NormalChange | AddChange | DeleteChange; 48 | -------------------------------------------------------------------------------- /packages/core/src/heading.ts: -------------------------------------------------------------------------------- 1 | import { Node, Element } from 'editure'; 2 | 3 | import { Step } from './interfaces'; 4 | 5 | export interface HeadingItem { 6 | id: string; 7 | title: string; 8 | type: string; 9 | commit?: string; 10 | } 11 | 12 | function isHeading(node: Node) { 13 | return [ 14 | 'heading-one', 15 | 'heading-two', 16 | 'heading-three', 17 | 'heading-four', 18 | 'heading-five', 19 | ].includes(node.type); 20 | } 21 | 22 | function getHeadingText(node: Element) { 23 | return node.children.map((child) => child.text).join(''); 24 | } 25 | 26 | export function getHeadings(nodes: Node[]): HeadingItem[] { 27 | return nodes.flatMap((node) => { 28 | if (isHeading(node)) { 29 | return { 30 | ...(node as Partial), 31 | title: getHeadingText(node as Element), 32 | } as HeadingItem; 33 | } 34 | if (node.children) { 35 | return getHeadings(node.children); 36 | } 37 | return []; 38 | }); 39 | } 40 | 41 | export function getStepTitle(nowStep: Step) { 42 | return getHeadings([nowStep]).filter((node) => node.commit)[0].title; 43 | } 44 | -------------------------------------------------------------------------------- /packages/core/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './interfaces'; 2 | export * from './constants'; 3 | export * from './utils'; 4 | export * from './diff'; 5 | export * from './steps'; 6 | export * from './heading'; 7 | export * from './version'; 8 | -------------------------------------------------------------------------------- /packages/core/src/interfaces.ts: -------------------------------------------------------------------------------- 1 | import { Element } from 'editure'; 2 | 3 | import { DiffFile } from './diff'; 4 | 5 | export interface RawDiff { 6 | commit: string; 7 | diff: DiffFile[]; 8 | } 9 | 10 | export interface Explain extends Element { 11 | type: 'explain'; 12 | fixed: true; 13 | } 14 | 15 | export interface DiffBlock extends Element { 16 | type: 'diff-block'; 17 | file: string; 18 | commit: string; 19 | hiddenLines?: [number, number][]; 20 | } 21 | 22 | export interface File extends Element { 23 | type: 'file'; 24 | file: string; 25 | display?: boolean; 26 | children: [Explain, DiffBlock, Explain]; 27 | } 28 | 29 | export interface Meta { 30 | name: string; 31 | description?: string; 32 | id: string; 33 | cover?: string; 34 | created?: Date; 35 | topics?: string[]; 36 | categories?: string[]; 37 | github?: string; 38 | } 39 | 40 | export interface Article extends Meta {} 41 | 42 | export interface StepTitle extends Element { 43 | type: 'heading-two'; 44 | commit: string; 45 | id: string; 46 | fixed: true; 47 | } 48 | 49 | export type StepChild = StepTitle | Explain | File; 50 | 51 | export interface Step extends Element { 52 | type: 'step'; 53 | id: string; 54 | articleId?: string | null; 55 | outdated?: boolean; 56 | commit: string; 57 | children: StepChild[]; 58 | } 59 | 60 | export interface Remote { 61 | name: string; 62 | refs: { 63 | fetch: string; 64 | push: string; 65 | }; 66 | } 67 | 68 | export interface Collection extends Meta { 69 | version?: string; 70 | articles: Article[]; 71 | remotes?: Remote[]; 72 | steps: Step[]; 73 | } 74 | 75 | export interface TutureConfig { 76 | ignoredFiles: string[]; 77 | port: number; 78 | } 79 | -------------------------------------------------------------------------------- /packages/core/src/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generate a random hex number. 3 | */ 4 | export function randHex(digits: number = 8) { 5 | return Math.random() 6 | .toString(16) 7 | .slice(2, digits + 2); 8 | } 9 | 10 | /** 11 | * Compare if two commit hash is equal. 12 | */ 13 | export function isCommitEqual( 14 | commit1?: string | null, 15 | commit2?: string | null, 16 | ) { 17 | if (!commit1 || !commit2) { 18 | return false; 19 | } 20 | return ( 21 | commit1.startsWith(String(commit2)) || commit2.startsWith(String(commit1)) 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /packages/core/src/version.ts: -------------------------------------------------------------------------------- 1 | export const SCHEMA_VERSION = 'v1'; 2 | -------------------------------------------------------------------------------- /packages/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "./src", 5 | "outDir": "./dist", 6 | "composite": true, 7 | "declaration": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/editor/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /packages/editor/.eslintignore: -------------------------------------------------------------------------------- 1 | *.md 2 | *.json 3 | .github 4 | .next 5 | build 6 | dist 7 | node_modules 8 | tmp 9 | yarn.lock 10 | package.json 11 | next.config.js 12 | *.png 13 | *.svg 14 | 15 | src/serviceWorker.js 16 | -------------------------------------------------------------------------------- /packages/editor/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /packages/editor/.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | singleQuote: true, 3 | trailingComma: 'all', 4 | printWidth: 80, 5 | arrowParens: 'always', 6 | }; 7 | -------------------------------------------------------------------------------- /packages/editor/README.md: -------------------------------------------------------------------------------- 1 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 2 | 3 | ## Available Scripts 4 | 5 | In the project directory, you can run: 6 | 7 | ### `yarn start` 8 | 9 | Runs the app in the development mode.
10 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 11 | 12 | The page will reload if you make edits.
13 | You will also see any lint errors in the console. 14 | 15 | ### `yarn test` 16 | 17 | Launches the test runner in the interactive watch mode.
18 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 19 | 20 | ### `yarn build` 21 | 22 | Builds the app for production to the `build` folder.
23 | It correctly bundles React in production mode and optimizes the build for the best performance. 24 | 25 | The build is minified and the filenames include the hashes.
26 | Your app is ready to be deployed! 27 | 28 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 29 | 30 | ### `yarn eject` 31 | 32 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 33 | 34 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 35 | 36 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 37 | 38 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 39 | 40 | ## Learn More 41 | 42 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 43 | 44 | To learn React, check out the [React documentation](https://reactjs.org/). 45 | 46 | ### Code Splitting 47 | 48 | This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting 49 | 50 | ### Analyzing the Bundle Size 51 | 52 | This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size 53 | 54 | ### Making a Progressive Web App 55 | 56 | This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app 57 | 58 | ### Advanced Configuration 59 | 60 | This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration 61 | 62 | ### Deployment 63 | 64 | This section has moved here: https://facebook.github.io/create-react-app/docs/deployment 65 | 66 | ### `yarn build` fails to minify 67 | 68 | This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify 69 | -------------------------------------------------------------------------------- /packages/editor/api-server.js: -------------------------------------------------------------------------------- 1 | const { makeServer } = require('@tuture/local-server'); 2 | const mockRemotes = require('./fixtures/mock-remotes.json'); 3 | 4 | const PORT = 8000; 5 | 6 | const app = makeServer({ 7 | baseUrl: '/api', 8 | mockRoutes: (app) => { 9 | app.get('/api/remotes', (req, res) => { 10 | res.json(mockRemotes); 11 | }); 12 | 13 | app.get('/api/sync', (req, res) => { 14 | setTimeout(() => { 15 | res.sendStatus(200); 16 | }, 2000); 17 | }); 18 | }, 19 | }); 20 | 21 | app.listen(PORT, () => { 22 | console.log(`API server is running on http://localhost:${PORT}!`); 23 | }); 24 | -------------------------------------------------------------------------------- /packages/editor/config-overrides.js: -------------------------------------------------------------------------------- 1 | const { override, fixBabelImports, addLessLoader } = require('customize-cra'); 2 | 3 | module.exports = override( 4 | fixBabelImports('import', { 5 | libraryName: 'antd', 6 | libraryDirectory: 'es', 7 | style: true, 8 | }), 9 | fixBabelImports('emotion'), 10 | addLessLoader({ 11 | javascriptEnabled: true, 12 | modifyVars: { 13 | '@primary-color': '#02b875', 14 | '@text-selection-bg': '#d8d8dc', 15 | '@text-color-inverse': 'inherit', 16 | }, 17 | }), 18 | ); 19 | -------------------------------------------------------------------------------- /packages/editor/fixtures/mock-remotes.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "origin", 4 | "refs": { 5 | "fetch": "https://github.com/tuture-dev/tuture.git", 6 | "push": "https://github.com/tuture-dev/tuture.git" 7 | } 8 | }, 9 | { 10 | "name": "gitlab", 11 | "refs": { 12 | "fetch": "https://gitlab.com/tuture-dev/tuture.git", 13 | "push": "https://gitlab.com/tuture-dev/tuture.git" 14 | } 15 | }, 16 | { 17 | "name": "coding", 18 | "refs": { 19 | "fetch": "https://e.coding.net/tuture-dev/tuture.git", 20 | "push": "https://e.coding.net/tuture-dev/tuture.git" 21 | } 22 | } 23 | ] 24 | -------------------------------------------------------------------------------- /packages/editor/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@tuture/editor", 3 | "version": "0.0.7", 4 | "private": true, 5 | "dependencies": { 6 | "@emotion/core": "^10.0.27", 7 | "@emotion/styled": "^10.0.27", 8 | "@rematch/core": "^1.4.0", 9 | "@rematch/immer": "^1.2.0", 10 | "@rematch/loading": "^1.2.1", 11 | "@rematch/select": "^2.0.5", 12 | "@tuture/core": "^0.0.7", 13 | "@tuture/local-server": "^0.0.7", 14 | "@types/axios": "^0.14.0", 15 | "antd": "^3.26.9", 16 | "axios": "^0.19.2", 17 | "customize-cra": "^0.9.1", 18 | "dayjs": "^1.8.20", 19 | "editure": "0.1.13", 20 | "editure-constants": "0.1.2", 21 | "editure-react": "0.1.13", 22 | "emotion": "^10.0.27", 23 | "is-hotkey": "^0.1.6", 24 | "lodash.omit": "^4.5.0", 25 | "lodash.pick": "^4.4.0", 26 | "prism-react-renderer": "^1.0.2", 27 | "react": "^16.12.0", 28 | "react-dom": "^16.12.0", 29 | "react-lazy-load": "^3.0.13", 30 | "react-redux": "^7.1.3", 31 | "react-router-dom": "^5.1.2", 32 | "react-scripts": "3.4.0", 33 | "react-smooth-dnd": "^0.11.1", 34 | "react-table": "^7.0.0-rc.16", 35 | "react-use": "^14.1.1", 36 | "refractor": "^2.10.1", 37 | "yutang": "^0.0.4" 38 | }, 39 | "scripts": { 40 | "start:api": "cross-env TUTURE_PATH=fixtures NODE_ENV=development node api-server.js", 41 | "start": "concurrently \"yarn start:api\" \"react-app-rewired start\"", 42 | "build": "react-app-rewired build", 43 | "postbuild": "rimraf build/**/*.map", 44 | "analyze": "source-map-explorer 'build/static/js/*.js'", 45 | "test": "react-app-rewired test", 46 | "eject": "react-scripts eject", 47 | "lint": "../node_modules/.bin/eslint . --fix" 48 | }, 49 | "eslintConfig": { 50 | "extends": "react-app" 51 | }, 52 | "proxy": "http://localhost:8000/", 53 | "browserslist": { 54 | "production": [ 55 | ">0.2%", 56 | "not dead", 57 | "not op_mini all" 58 | ], 59 | "development": [ 60 | "last 1 chrome version", 61 | "last 1 firefox version", 62 | "last 1 safari version" 63 | ] 64 | }, 65 | "devDependencies": { 66 | "@testing-library/jest-dom": "^4.2.4", 67 | "@testing-library/react": "^9.3.2", 68 | "@testing-library/user-event": "^7.1.2", 69 | "@types/express": "^4.16.0", 70 | "@types/lodash.omit": "^4.5.6", 71 | "@types/lodash.pick": "^4.4.6", 72 | "@types/node": "^12.12.31", 73 | "@types/react": "^16.9.25", 74 | "@types/react-redux": "^7.1.7", 75 | "@types/react-router-dom": "^5.1.3", 76 | "@types/react-table": "^7.0.12", 77 | "@types/refractor": "^2.8.0", 78 | "babel-plugin-emotion": "^10.0.27", 79 | "babel-plugin-import": "^1.13.0", 80 | "concurrently": "^5.1.0", 81 | "cross-env": "^7.0.2", 82 | "less": "^3.11.1", 83 | "less-loader": "^5.0.0", 84 | "react-app-rewired": "^2.1.5", 85 | "source-map-explorer": "^2.4.2" 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /packages/editor/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuture-dev/tuture/7615e76607c8930c54367aaff44b4334eefeb6f7/packages/editor/public/favicon.ico -------------------------------------------------------------------------------- /packages/editor/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | 图雀写作工具 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /packages/editor/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuture-dev/tuture/7615e76607c8930c54367aaff44b4334eefeb6f7/packages/editor/public/logo192.png -------------------------------------------------------------------------------- /packages/editor/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuture-dev/tuture/7615e76607c8930c54367aaff44b4334eefeb6f7/packages/editor/public/logo512.png -------------------------------------------------------------------------------- /packages/editor/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /packages/editor/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /packages/editor/src/assets/antd-custom.less: -------------------------------------------------------------------------------- 1 | @primary-color: #02b875; // 全局主色 2 | @link-color: #02b875; // 链接色 3 | -------------------------------------------------------------------------------- /packages/editor/src/assets/images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | logo 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /packages/editor/src/components/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Content from './Content'; 4 | 5 | function App() { 6 | return ( 7 |
8 | 9 |
10 | ); 11 | } 12 | 13 | export default App; 14 | -------------------------------------------------------------------------------- /packages/editor/src/components/ConnectedLayout/ChildrenDrawerComponent/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useSelector, useDispatch } from 'react-redux'; 3 | import { Drawer } from 'antd'; 4 | 5 | import { Dispatch, RootState } from 'store'; 6 | import { CREATE_ARTICLE, EDIT_ARTICLE } from 'utils/constants'; 7 | 8 | import CreateEditArticle from './CreateEditArticle'; 9 | 10 | const mapTypeToTitle: { [key: string]: string } = { 11 | [CREATE_ARTICLE]: '新建文章', 12 | [EDIT_ARTICLE]: '编辑文章', 13 | }; 14 | 15 | function ChildrenDrawerComponent() { 16 | const dispatch = useDispatch(); 17 | const { childrenDrawerType, childrenVisible } = useSelector( 18 | (state: RootState) => state.drawer, 19 | ); 20 | 21 | function onClose() { 22 | dispatch.drawer.setChildrenVisible(false); 23 | } 24 | 25 | return ( 26 | 36 | 37 | 38 | ); 39 | } 40 | 41 | export default ChildrenDrawerComponent; 42 | -------------------------------------------------------------------------------- /packages/editor/src/components/ConnectedLayout/DrawerComponent/ContactUs.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import { css, jsx } from '@emotion/core'; 3 | 4 | import IconFont from 'components/IconFont'; 5 | import logo from 'assets/images/logo.svg'; 6 | 7 | const link = css` 8 | width: 252px; 9 | height: 40px; 10 | display: flex; 11 | flex-direction: row; 12 | align-items: center; 13 | background: rgba(255, 255, 255, 1); 14 | border-radius: 4px; 15 | border: 1px solid rgba(232, 232, 232, 1); 16 | display: flex; 17 | margin-top: 16px; 18 | padding: 0 16px; 19 | 20 | &:hover { 21 | box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.08); 22 | } 23 | 24 | transition: box-shadow 0.3s; 25 | `; 26 | const icon = css` 27 | margin: 0; 28 | padding: 0; 29 | 30 | & > svg { 31 | width: 24px; 32 | height: 24px; 33 | } 34 | `; 35 | 36 | const labelText = css` 37 | margin-left: 8px; 38 | display: inline-block; 39 | width: 120px; 40 | font-size: 14px; 41 | font-family: PingFangSC-Regular, PingFang SC; 42 | font-weight: 400; 43 | color: rgba(0, 0, 0, 1); 44 | `; 45 | 46 | const arrow = css` 47 | width: 10px; 48 | height: 10px; 49 | margin: 0; 50 | padding: 0; 51 | margin-left: 80px; 52 | color: #565d64; 53 | 54 | &:hover { 55 | color: #02b875; 56 | } 57 | 58 | transition: color 0.3s; 59 | `; 60 | const itemsData = [ 61 | { 62 | id: '1', 63 | href: 'https://tuture.co/', 64 | icon: { logo }, 65 | labelText: '图雀社区主站', 66 | }, 67 | { 68 | id: '2', 69 | href: 'https://github.com/tuture-dev/tuture', 70 | icon: 'icon-github-fill', 71 | labelText: '写作工具地址', 72 | }, 73 | { 74 | id: '3', 75 | href: 'https://zhuanlan.zhihu.com/tuture', 76 | icon: 'icon-zhihu-circle-fill', 77 | labelText: '知乎专栏', 78 | }, 79 | { 80 | id: '4', 81 | href: 'https://tuture.co/images/social/wechat.png', 82 | icon: 'icon-wechat', 83 | labelText: '微信公众号', 84 | }, 85 | { 86 | id: '5', 87 | href: 'https://juejin.im/user/5b33414351882574b9694d28', 88 | icon: 'icon-juejin', 89 | labelText: '掘金专栏', 90 | }, 91 | { 92 | id: '6', 93 | href: 'https://www.imooc.com/u/8413857/articles', 94 | icon: 'icon-mukewang', 95 | labelText: '慕课手记', 96 | }, 97 | ]; 98 | const item_f = itemsData[0]; 99 | itemsData.shift(); 100 | const items = itemsData.map((item) => ( 101 | 108 | 109 | {item.labelText} 110 | 111 | 112 | )); 113 | 114 | function ContactUs() { 115 | return ( 116 |
125 | 132 | tuture 133 | {item_f.labelText} 134 | 135 | 136 | {items} 137 |
138 | ); 139 | } 140 | 141 | export default ContactUs; 142 | -------------------------------------------------------------------------------- /packages/editor/src/components/ConnectedLayout/DrawerComponent/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from 'react'; 2 | import { useSelector, useDispatch } from 'react-redux'; 3 | import { Drawer } from 'antd'; 4 | 5 | import { 6 | COLLECTION_CATALOGUE, 7 | COLLECTION_SETTING, 8 | CONTACT_US, 9 | } from 'utils/constants'; 10 | 11 | import CollectionCatalogue from './CollectionCatalogue'; 12 | import CollectionSetting from './CollectionSetting'; 13 | import ContactUs from './ContactUs'; 14 | 15 | import { RootState, Dispatch } from 'store'; 16 | 17 | const mapTypeToTitle: { [key: string]: string } = { 18 | [COLLECTION_CATALOGUE]: '文集目录', 19 | [COLLECTION_SETTING]: '文集设置', 20 | [CONTACT_US]: '联系我们', 21 | }; 22 | 23 | const mapTypeToComponent: { [key: string]: ReactNode } = { 24 | [COLLECTION_CATALOGUE]: , 25 | [COLLECTION_SETTING]: , 26 | [CONTACT_US]: , 27 | }; 28 | 29 | function DrawerComponent() { 30 | const dispatch = useDispatch(); 31 | const { drawerType, visible } = useSelector( 32 | (state: RootState) => state.drawer, 33 | ); 34 | 35 | const RenderComponent = mapTypeToComponent[drawerType]; 36 | 37 | const handleClose = () => { 38 | dispatch.drawer.setVisible(false); 39 | dispatch.drawer.setSelectedKeys([]); 40 | }; 41 | 42 | return ( 43 | 58 | {RenderComponent} 59 | 60 | ); 61 | } 62 | 63 | export default DrawerComponent; 64 | -------------------------------------------------------------------------------- /packages/editor/src/components/ConnectedLayout/LayoutHeader/LastSavedTimestamp.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import dayjs from 'dayjs'; 3 | import { useSelector } from 'react-redux'; 4 | 5 | import { RootState } from 'store'; 6 | 7 | function LastSavedTimestamp() { 8 | const saveFailed = useSelector( 9 | (state: RootState) => state.collection.saveFailed, 10 | ); 11 | const lastSaved = useSelector( 12 | (state: RootState) => state.collection.lastSaved, 13 | ); 14 | 15 | if (saveFailed) { 16 | return
保存失败,请检查服务器连接!
; 17 | } 18 | 19 | return ( 20 |
21 | {lastSaved 22 | ? `上次保存时间:今天 ${dayjs(lastSaved).format('HH:mm')}` 23 | : ''} 24 |
25 | ); 26 | } 27 | 28 | export default LastSavedTimestamp; 29 | -------------------------------------------------------------------------------- /packages/editor/src/components/ConnectedLayout/LayoutHeader/Toolbar/BlockButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useEditure } from 'editure-react'; 3 | 4 | import { IEditor } from 'utils/editor'; 5 | import { BLOCK_HOTKEYS, getHotkeyHint } from 'utils/hotkeys'; 6 | 7 | import Button from './Button'; 8 | import ToolbarIcon from './ToolbarIcon'; 9 | 10 | type BlockButtonProps = { 11 | format: string; 12 | icon: string; 13 | }; 14 | 15 | const BlockButton = ({ format, icon }: BlockButtonProps) => { 16 | const editor = useEditure() as IEditor; 17 | const isActive = editor.isBlockActive(format); 18 | const { hotkey, title } = BLOCK_HOTKEYS[format]; 19 | 20 | return ( 21 | 33 | ); 34 | }; 35 | 36 | export default BlockButton; 37 | -------------------------------------------------------------------------------- /packages/editor/src/components/ConnectedLayout/LayoutHeader/Toolbar/Button.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | /** @jsx jsx */ 4 | import { css, jsx } from '@emotion/core'; 5 | 6 | type ButtonProps = { 7 | className: string; 8 | handleMouseDown: React.MouseEventHandler; 9 | handleClick: React.MouseEventHandler; 10 | [prop: string]: any; 11 | }; 12 | 13 | const Button = React.forwardRef( 14 | ({ className, handleMouseDown, handleClick, ...props }, ref) => ( 15 | 25 | ), 26 | ); 27 | 28 | export default Button; 29 | -------------------------------------------------------------------------------- /packages/editor/src/components/ConnectedLayout/LayoutHeader/Toolbar/EditLink.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import { Modal, Input } from 'antd'; 4 | import { useEditure } from 'editure-react'; 5 | 6 | /** @jsx jsx */ 7 | import { css, jsx } from '@emotion/core'; 8 | 9 | import { selectLastPoint } from 'editure'; 10 | import { LINK } from 'editure-constants'; 11 | 12 | import { IEditor, syncDOMSelection } from 'utils/editor'; 13 | import { Dispatch, RootState } from 'store'; 14 | 15 | const EditLink = () => { 16 | const editor = useEditure() as IEditor; 17 | const dispatch = useDispatch(); 18 | const { isEditing, text, url } = useSelector( 19 | (state: RootState) => state.link, 20 | ); 21 | 22 | const handleOk = () => { 23 | // Go back to last selected point. 24 | selectLastPoint(editor); 25 | syncDOMSelection(editor); 26 | 27 | if (text) { 28 | if (!editor.isMarkActive(LINK)) { 29 | editor.insertLink({ text, url }); 30 | } else { 31 | editor.updateLink({ text, url }); 32 | } 33 | } 34 | 35 | dispatch.link.reset(); 36 | }; 37 | 38 | const handleCancel = () => { 39 | selectLastPoint(editor); 40 | syncDOMSelection(editor); 41 | dispatch.link.reset(); 42 | }; 43 | 44 | const onKeyDown = (e: React.KeyboardEvent) => { 45 | if (e.keyCode === 13) { 46 | // Enter key. 47 | e.preventDefault(); 48 | handleOk(); 49 | } 50 | }; 51 | 52 | return ( 53 | 61 |

67 | 文本 68 |

69 | dispatch.link.setText(e.target.value)} 75 | /> 76 |

82 | 链接 83 |

84 | dispatch.link.setUrl(e.target.value)} 90 | /> 91 |
92 | ); 93 | }; 94 | 95 | export default EditLink; 96 | -------------------------------------------------------------------------------- /packages/editor/src/components/ConnectedLayout/LayoutHeader/Toolbar/HistoryButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useEditure } from 'editure-react'; 3 | 4 | import { IEditor } from 'utils/editor'; 5 | import { OP_HOTKEYS, getHotkeyHint } from 'utils/hotkeys'; 6 | 7 | import Button from './Button'; 8 | import ToolbarIcon from './ToolbarIcon'; 9 | 10 | type HistoryButtonProps = { 11 | action: 'undo' | 'redo'; 12 | icon: string; 13 | }; 14 | 15 | const HistoryButton = ({ action, icon }: HistoryButtonProps) => { 16 | const editor = useEditure() as IEditor; 17 | const { hotkey, title } = OP_HOTKEYS[action]; 18 | 19 | return ( 20 | 33 | ); 34 | }; 35 | 36 | export default HistoryButton; 37 | -------------------------------------------------------------------------------- /packages/editor/src/components/ConnectedLayout/LayoutHeader/Toolbar/HrButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useEditure } from 'editure-react'; 3 | import { HR } from 'editure-constants'; 4 | 5 | import { IEditor } from 'utils/editor'; 6 | import { BLOCK_HOTKEYS, getHotkeyHint } from 'utils/hotkeys'; 7 | 8 | import Button from './Button'; 9 | import ToolbarIcon from './ToolbarIcon'; 10 | 11 | const HrButton = () => { 12 | const editor = useEditure() as IEditor; 13 | const { hotkey, title } = BLOCK_HOTKEYS[HR]; 14 | 15 | const onMouseDown = (event: React.SyntheticEvent) => { 16 | event.preventDefault(); 17 | editor.insertVoid(HR); 18 | }; 19 | 20 | return ( 21 | 27 | ); 28 | }; 29 | 30 | export default HrButton; 31 | -------------------------------------------------------------------------------- /packages/editor/src/components/ConnectedLayout/LayoutHeader/Toolbar/ImageButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import { useEditure } from 'editure-react'; 3 | import { IMAGE } from 'editure-constants'; 4 | 5 | /** @jsx jsx */ 6 | import { css, jsx } from '@emotion/core'; 7 | 8 | import { IEditor } from 'utils/editor'; 9 | import { BLOCK_HOTKEYS, getHotkeyHint, ButtonRefsContext } from 'utils/hotkeys'; 10 | import { insertImage } from 'utils/image'; 11 | 12 | import Button from './Button'; 13 | import ToolbarIcon from './ToolbarIcon'; 14 | 15 | const ImageButton = () => { 16 | const editor = useEditure() as IEditor; 17 | const { imageBtnRef: ref } = useContext(ButtonRefsContext); 18 | const { hotkey, title } = BLOCK_HOTKEYS[IMAGE]; 19 | 20 | const onChange = (e: React.ChangeEvent) => { 21 | e.persist(); 22 | 23 | if (e.target.files) { 24 | insertImage(editor, e.target.files); 25 | } 26 | }; 27 | 28 | return ( 29 | 54 | ); 55 | }; 56 | 57 | export default ImageButton; 58 | -------------------------------------------------------------------------------- /packages/editor/src/components/ConnectedLayout/LayoutHeader/Toolbar/LinkButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import { useDispatch } from 'react-redux'; 3 | import { useEditure } from 'editure-react'; 4 | import { LINK } from 'editure-constants'; 5 | import { getSelectedString, selectLastPoint } from 'editure'; 6 | 7 | import { IEditor } from 'utils/editor'; 8 | import { Dispatch } from 'store'; 9 | import { MARK_HOTKEYS, getHotkeyHint, ButtonRefsContext } from 'utils/hotkeys'; 10 | 11 | import Button from './Button'; 12 | import ToolbarIcon from './ToolbarIcon'; 13 | 14 | const LinkButton = () => { 15 | const editor = useEditure() as IEditor; 16 | const dispatch = useDispatch(); 17 | const { linkBtnRef: ref } = useContext(ButtonRefsContext); 18 | const { hotkey, title } = MARK_HOTKEYS[LINK]; 19 | 20 | const isActive = editor.isMarkActive(LINK); 21 | 22 | const onClick = () => { 23 | selectLastPoint(editor); 24 | 25 | const { selection } = editor; 26 | if (!selection) return; 27 | 28 | if (editor.isMarkActive(LINK)) { 29 | return editor.removeLink(); 30 | } 31 | 32 | const selected = getSelectedString(editor); 33 | 34 | if (selected) { 35 | dispatch.link.setText(selected); 36 | } 37 | 38 | dispatch.link.startEdit(); 39 | }; 40 | 41 | return ( 42 | 49 | ); 50 | }; 51 | 52 | export default LinkButton; 53 | -------------------------------------------------------------------------------- /packages/editor/src/components/ConnectedLayout/LayoutHeader/Toolbar/MarkButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useEditure } from 'editure-react'; 3 | 4 | import { IEditor } from 'utils/editor'; 5 | import { MARK_HOTKEYS, getHotkeyHint } from 'utils/hotkeys'; 6 | 7 | import Button from './Button'; 8 | import ToolbarIcon from './ToolbarIcon'; 9 | 10 | type MarkButtonProps = { 11 | format: string; 12 | icon: string; 13 | }; 14 | 15 | const MarkButton = ({ format, icon }: MarkButtonProps) => { 16 | const editor = useEditure() as IEditor; 17 | const isActive = editor.isMarkActive(format); 18 | const { hotkey, title } = MARK_HOTKEYS[format]; 19 | 20 | return ( 21 | 33 | ); 34 | }; 35 | 36 | export default MarkButton; 37 | -------------------------------------------------------------------------------- /packages/editor/src/components/ConnectedLayout/LayoutHeader/Toolbar/Menu.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | /** @jsx jsx */ 4 | import { css, jsx } from '@emotion/core'; 5 | 6 | type MenuProps = { 7 | className?: string; 8 | [prop: string]: any; 9 | }; 10 | 11 | const Menu = ({ className, ...props }: MenuProps) => ( 12 |
* { 17 | display: inline-block; 18 | } 19 | `} 20 | /> 21 | ); 22 | 23 | export default Menu; 24 | -------------------------------------------------------------------------------- /packages/editor/src/components/ConnectedLayout/LayoutHeader/Toolbar/NoteButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Dropdown, Menu } from 'antd'; 3 | import { ClickParam } from 'antd/lib/menu'; 4 | import { useEditure } from 'editure-react'; 5 | import { selectLastPoint } from 'editure'; 6 | import { NOTE } from 'editure-constants'; 7 | 8 | import { IEditor, syncDOMSelection } from 'utils/editor'; 9 | import { levels } from 'utils/note'; 10 | import IconFont from 'components/IconFont'; 11 | 12 | import Button from './Button'; 13 | import ToolbarIcon from './ToolbarIcon'; 14 | 15 | const NoteButton = () => { 16 | const editor = useEditure() as IEditor; 17 | 18 | const handleClick = (e: ClickParam) => { 19 | selectLastPoint(editor); 20 | editor.toggleBlock(NOTE, { level: e.key }); 21 | 22 | syncDOMSelection(editor); 23 | }; 24 | 25 | const menu = ( 26 | 27 | {Object.keys(levels).map((level) => ( 28 | 29 | 30 | {levels[level].name} 31 | 32 | ))} 33 | 34 | ); 35 | 36 | return ( 37 | 38 | 41 | 42 | ); 43 | }; 44 | 45 | export default NoteButton; 46 | -------------------------------------------------------------------------------- /packages/editor/src/components/ConnectedLayout/LayoutHeader/Toolbar/SaveButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import { useDispatch } from 'react-redux'; 3 | 4 | import { ButtonRefsContext } from 'utils/hotkeys'; 5 | import { Dispatch } from 'store'; 6 | 7 | import Button from './Button'; 8 | import ToolbarIcon from './ToolbarIcon'; 9 | 10 | const SaveButton = () => { 11 | const dispatch = useDispatch(); 12 | const { saveBtnRef: ref } = useContext(ButtonRefsContext); 13 | 14 | const onClick = () => { 15 | dispatch.collection.save({ showMessage: true }); 16 | }; 17 | 18 | return ( 19 | 22 | ); 23 | }; 24 | 25 | export default SaveButton; 26 | -------------------------------------------------------------------------------- /packages/editor/src/components/ConnectedLayout/LayoutHeader/Toolbar/SelectContentType.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Select } from 'antd'; 3 | import { useEditure } from 'editure-react'; 4 | import { H1, H2, H3, H4, H5, PARAGRAPH } from 'editure-constants'; 5 | 6 | import { IEditor, syncDOMSelection } from 'utils/editor'; 7 | import IconFont from 'components/IconFont'; 8 | 9 | /** @jsx jsx */ 10 | import { css, jsx } from '@emotion/core'; 11 | 12 | const { Option } = Select; 13 | 14 | type ContentType = { 15 | name: string; 16 | fontSize: string; 17 | fontWeight: number; 18 | }; 19 | 20 | const types: Record = { 21 | [H1]: { name: '标题 1', fontSize: '24px', fontWeight: 500 }, 22 | [H2]: { name: '标题 2', fontSize: '22px', fontWeight: 500 }, 23 | [H3]: { name: '标题 3', fontSize: '20px', fontWeight: 500 }, 24 | [H4]: { name: '标题 4', fontSize: '18px', fontWeight: 500 }, 25 | [H5]: { name: '标题 5', fontSize: '16px', fontWeight: 500 }, 26 | [PARAGRAPH]: { name: '正文', fontSize: '14px', fontWeight: 400 }, 27 | }; 28 | 29 | const SelectContentType = () => { 30 | const editor = useEditure() as IEditor; 31 | const type = editor.detectBlockFormat(Object.keys(types)) || PARAGRAPH; 32 | 33 | const handleChange = (value: string) => { 34 | editor.toggleBlock(value); 35 | syncDOMSelection(editor); 36 | }; 37 | 38 | const suffixIcon = ; 39 | 40 | return ( 41 | 73 | ); 74 | }; 75 | 76 | export default SelectContentType; 77 | -------------------------------------------------------------------------------- /packages/editor/src/components/ConnectedLayout/LayoutHeader/Toolbar/SplitLine.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | /** @jsx jsx */ 4 | import { css, jsx } from '@emotion/core'; 5 | 6 | const SplitLine = () => { 7 | return ( 8 | 16 | | 17 | 18 | ); 19 | }; 20 | 21 | export default SplitLine; 22 | -------------------------------------------------------------------------------- /packages/editor/src/components/ConnectedLayout/LayoutHeader/Toolbar/ToolbarIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Tooltip } from 'antd'; 3 | 4 | /** @jsx jsx */ 5 | import { css, jsx } from '@emotion/core'; 6 | 7 | import IconFont from 'components/IconFont'; 8 | 9 | type ToolbarIconProps = { 10 | isActive?: boolean; 11 | icon: string; 12 | title: string; 13 | }; 14 | 15 | const ToolbarIcon = ({ isActive = false, icon, title }: ToolbarIconProps) => ( 16 | 17 | 32 | 33 | ); 34 | 35 | export default ToolbarIcon; 36 | -------------------------------------------------------------------------------- /packages/editor/src/components/ConnectedLayout/LayoutHeader/Toolbar/index.tsx: -------------------------------------------------------------------------------- 1 | import * as F from 'editure-constants'; 2 | 3 | /** @jsx jsx */ 4 | import { css, jsx } from '@emotion/core'; 5 | 6 | import React from 'react'; 7 | import Menu from './Menu'; 8 | import EditLink from './EditLink'; 9 | import SplitLine from './SplitLine'; 10 | import SaveButton from './SaveButton'; 11 | import MarkButton from './MarkButton'; 12 | import BlockButton from './BlockButton'; 13 | import ImageButton from './ImageButton'; 14 | import HrButton from './HrButton'; 15 | import NoteButton from './NoteButton'; 16 | import LinkButton from './LinkButton'; 17 | import HistoryButton from './HistoryButton'; 18 | import SelectContentType from './SelectContentType'; 19 | 20 | const Toolbar = (props: { [prop: string]: any }) => { 21 | return ( 22 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | ); 85 | }; 86 | 87 | export default Toolbar; 88 | -------------------------------------------------------------------------------- /packages/editor/src/components/ConnectedLayout/LayoutHeader/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | /** @jsx jsx */ 4 | import { css, jsx } from '@emotion/core'; 5 | import { Button, Row, Col, Breadcrumb } from 'antd'; 6 | import { useDispatch, useSelector } from 'react-redux'; 7 | import { useRouteMatch, Link } from 'react-router-dom'; 8 | 9 | import ToolBar from './Toolbar'; 10 | import SyncModal from './SyncModal'; 11 | import LastSavedTimestamp from './LastSavedTimestamp'; 12 | 13 | import { Dispatch, RootState } from 'store'; 14 | 15 | function LayoutHeader() { 16 | const dispatch = useDispatch(); 17 | const { meta } = useSelector((state: RootState) => state.collection); 18 | const { name = '' } = meta || {}; 19 | 20 | const isSavingToc = useSelector( 21 | (state: RootState) => state.loading.effects.toc.save, 22 | ); 23 | const isSyncing = useSelector( 24 | (state: RootState) => state.loading.effects.sync.sync, 25 | ); 26 | 27 | const isToc = useRouteMatch('/toc'); 28 | 29 | function handleSaveToc() { 30 | dispatch.toc.save(true); 31 | } 32 | 33 | return ( 34 | 35 | 36 | {isToc ? ( 37 | 42 | 43 | {name} 44 | 45 | 文集目录 46 | 47 | ) : ( 48 | 49 | )} 50 | 51 | 52 | {!isToc && } 53 | 54 | 55 | {isToc ? ( 56 | 59 | ) : ( 60 |
61 | 71 | 72 |
73 | )} 74 | 75 |
76 | ); 77 | } 78 | 79 | export default LayoutHeader; 80 | -------------------------------------------------------------------------------- /packages/editor/src/components/ConnectedLayout/MainMenu.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Layout, Menu, Icon } from 'antd'; 3 | import { ClickParam } from 'antd/lib/menu'; 4 | 5 | import { useSelector, useDispatch } from 'react-redux'; 6 | import { useHistory } from 'react-router-dom'; 7 | 8 | /** @jsx jsx */ 9 | import { css, jsx } from '@emotion/core'; 10 | 11 | import logo from 'assets/images/logo.svg'; 12 | import { 13 | COLLECTION_CATALOGUE, 14 | COLLECTION_SETTING, 15 | CONTACT_US, 16 | } from 'utils/constants'; 17 | import { Dispatch, RootState } from 'store'; 18 | 19 | const { Sider } = Layout; 20 | 21 | const mapKeyToDrawerType: { [key: string]: string } = { 22 | '1': COLLECTION_CATALOGUE, 23 | '3': COLLECTION_SETTING, 24 | '4': CONTACT_US, 25 | }; 26 | 27 | function MainMenu() { 28 | const dispatch = useDispatch(); 29 | const { visible, drawerType, childrenVisible, selectedKeys } = useSelector( 30 | (state: RootState) => state.drawer, 31 | ); 32 | const history = useHistory(); 33 | 34 | function onMenuClick({ key }: ClickParam) { 35 | if (key === '2') { 36 | dispatch.drawer.setVisible(false); 37 | dispatch.drawer.setSelectedKeys([key]); 38 | history.push('/toc'); 39 | 40 | return; 41 | } 42 | 43 | const toggleDrawerType = mapKeyToDrawerType[key]; 44 | 45 | if (!visible) { 46 | dispatch.drawer.setVisible(true); 47 | dispatch.drawer.setDrawerType(toggleDrawerType); 48 | dispatch.drawer.setSelectedKeys([key]); 49 | } else { 50 | if (drawerType === toggleDrawerType) { 51 | dispatch.drawer.setVisible(false); 52 | dispatch.drawer.setSelectedKeys([]); 53 | } else { 54 | dispatch.drawer.setDrawerType(toggleDrawerType); 55 | dispatch.drawer.setSelectedKeys([key]); 56 | } 57 | } 58 | 59 | if (childrenVisible) { 60 | dispatch.drawer.setChildrenVisible(false); 61 | } 62 | } 63 | 64 | return ( 65 | 75 |
83 | 93 |
94 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 |
119 | ); 120 | } 121 | 122 | export default MainMenu; 123 | -------------------------------------------------------------------------------- /packages/editor/src/components/ConnectedLayout/OutdatedNotification.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Button, Icon, notification } from 'antd'; 3 | 4 | /** @jsx jsx */ 5 | import { css, jsx } from '@emotion/core'; 6 | 7 | export function openOutdatedNotification(onClick: () => void) { 8 | const key: string = `open${Date.now()}`; 9 | const btn = ( 10 | 13 | ); 14 | const description = ( 15 |
16 |

在你的步骤列表里面存在过时的步骤,请及时迁移内容或删除这些步骤

17 |

25 | 35 | 43 | 什么是过时步骤? 44 | 45 |

46 |
47 | ); 48 | notification.warning({ 49 | message: '过时步骤提醒', 50 | description, 51 | btn, 52 | key, 53 | duration: null, 54 | onClick, 55 | }); 56 | } 57 | -------------------------------------------------------------------------------- /packages/editor/src/components/ConnectedLayout/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo, useEffect, ReactNode } from 'react'; 2 | 3 | import { useSelector, useDispatch, useStore } from 'react-redux'; 4 | import { Layout, Affix, BackTop } from 'antd'; 5 | import { Editure, ReactEditor } from 'editure-react'; 6 | import { Node, updateLastSelection } from 'editure'; 7 | import { useHistory } from 'react-router-dom'; 8 | import { Meta } from '@tuture/core'; 9 | 10 | /** @jsx jsx */ 11 | import { css, jsx } from '@emotion/core'; 12 | 13 | import { Store, Dispatch, RootState } from 'store'; 14 | import { initializeEditor } from 'utils/editor'; 15 | import { buttonRefs, ButtonRefsContext } from 'utils/hotkeys'; 16 | import { useDebouncedSave } from 'utils/hooks'; 17 | 18 | import { openOutdatedNotification } from './OutdatedNotification'; 19 | import LayoutHeader from './LayoutHeader'; 20 | import MainMenu from './MainMenu'; 21 | import DrawerComponent from './DrawerComponent'; 22 | import ChildrenDrawerComponent from './ChildrenDrawerComponent'; 23 | 24 | const { Header, Content } = Layout; 25 | 26 | function ConnectedLayout(props: { children: ReactNode }) { 27 | const { children } = props; 28 | const history = useHistory(); 29 | const editor = useMemo(initializeEditor, []) as ReactEditor; 30 | 31 | const store = useStore() as Store; 32 | const dispatch = useDispatch(); 33 | const { name: pageTitle } = 34 | useSelector(store.select.collection.nowArticleMeta) || {}; 35 | const { fragment, outdatedNotificationClicked } = useSelector( 36 | (state: RootState) => state.collection, 37 | ); 38 | 39 | const outdatedExisted = !!fragment.filter((node) => node.outdated).length; 40 | 41 | useEffect(() => { 42 | if (outdatedExisted) { 43 | openOutdatedNotification(() => { 44 | dispatch.collection.setOutdatedNotificationClicked(true); 45 | }); 46 | } 47 | }, [dispatch, outdatedExisted]); 48 | 49 | useEffect(() => { 50 | dispatch.diff.fetchDiff(); 51 | dispatch.collection.fetchMeta(); 52 | dispatch.collection.fetchArticles(); 53 | }, [dispatch]); 54 | 55 | useEffect(() => { 56 | if (pageTitle) { 57 | document.title = pageTitle; 58 | } 59 | }, [pageTitle]); 60 | 61 | useEffect(() => { 62 | if (outdatedNotificationClicked) { 63 | dispatch.collection.setOutdatedNotificationClicked(false); 64 | history.push('/toc'); 65 | } 66 | }, [dispatch, history, outdatedNotificationClicked]); 67 | 68 | const setDirty = useDebouncedSave(['fragment'], 3000, [fragment]); 69 | function onContentChange(val: Node[]) { 70 | if (editor.selection) { 71 | updateLastSelection(editor.selection); 72 | } 73 | 74 | dispatch.collection.setFragment(val); 75 | setDirty(true); 76 | } 77 | 78 | return ( 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 |
100 | 101 |
102 | 111 | {children} 112 | 117 | 118 |
119 |
120 |
121 |
122 | ); 123 | } 124 | 125 | export default ConnectedLayout; 126 | -------------------------------------------------------------------------------- /packages/editor/src/components/Content/Editure/element/CodeBlock.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Select } from 'antd'; 3 | import { useEditure } from 'editure-react'; 4 | import { CODE_BLOCK } from 'editure-constants'; 5 | import { useDispatch } from 'react-redux'; 6 | import { languages, getValidId } from 'yutang'; 7 | 8 | /** @jsx jsx */ 9 | import { css, jsx } from '@emotion/core'; 10 | 11 | import IconFont from 'components/IconFont'; 12 | import { IS_MAC } from 'utils/environment'; 13 | import { Dispatch } from 'store'; 14 | 15 | import { ElementProps } from './index'; 16 | 17 | const { Option } = Select; 18 | 19 | function CodeBlockElement(props: ElementProps) { 20 | const { element, attributes, children } = props; 21 | const { lang: defaultLang = 'Plain Text' } = element; 22 | const dispatch = useDispatch(); 23 | 24 | const [lang, setLang] = useState(defaultLang); 25 | const editor = useEditure(); 26 | 27 | function handleChange(value: string) { 28 | setLang(value); 29 | dispatch.slate.setLang(value); 30 | 31 | editor.updateBlock(CODE_BLOCK, { lang: value }); 32 | } 33 | 34 | const suffixIcon = ( 35 | 36 | ); 37 | 38 | return ( 39 |
52 |
53 | 69 |
70 |
77 | 94 | {children} 95 |
96 | 112 | {IS_MAC ? '按 ⌘+↩ 退出' : '按 ⌃+↩ 退出'} 113 | 114 |
115 |
116 | ); 117 | } 118 | 119 | export default CodeBlockElement; 120 | -------------------------------------------------------------------------------- /packages/editor/src/components/Content/Editure/element/Explain.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | /** @jsx jsx */ 4 | import { css, jsx } from '@emotion/core'; 5 | import { Node } from 'editure'; 6 | 7 | import { STEP_START, STEP_END, FILE_START, FILE_END } from 'utils/constants'; 8 | import { ElementProps } from './index'; 9 | 10 | const mapExplainTypeToContent = (explainType: string) => { 11 | switch (explainType) { 12 | case STEP_START: { 13 | return '撰写此步骤的前置解释 ...'; 14 | } 15 | 16 | case STEP_END: { 17 | return '撰写此步骤的后置解释 ...'; 18 | } 19 | 20 | case FILE_START: { 21 | return '撰写如下文件的前置解释 ...'; 22 | } 23 | 24 | case FILE_END: { 25 | return '撰写如上文件的后置解释 ...'; 26 | } 27 | 28 | default: { 29 | return '撰写一点解释 ...'; 30 | } 31 | } 32 | }; 33 | 34 | const mapExplainTypeToBorder = (explainType: string) => { 35 | switch (explainType) { 36 | case FILE_START: { 37 | return css` 38 | border-bottom: none; 39 | border-bottom-right-radius: 0; 40 | border-bottom-left-radius: 0; 41 | `; 42 | } 43 | 44 | case FILE_END: { 45 | return css` 46 | border-top: none; 47 | border-top-right-radius: 0; 48 | border-top-left-radius: 0; 49 | `; 50 | } 51 | 52 | default: { 53 | return null; 54 | } 55 | } 56 | }; 57 | 58 | const emptyChildrenStyles = (explainType: string) => css` 59 | border: 1px solid #ddd; 60 | position: relative; 61 | 62 | ${mapExplainTypeToBorder(explainType)} 63 | 64 | &::before { 65 | content: '${mapExplainTypeToContent(explainType)}'; 66 | position: absolute; 67 | bottom: 4px; 68 | color: #bfbfbf; 69 | } 70 | `; 71 | 72 | function ExplainElement(props: ElementProps) { 73 | const { attributes, children, element } = props; 74 | const explainStr = Node.string(element); 75 | 76 | return ( 77 |
97 | {children} 98 |
99 | ); 100 | } 101 | 102 | export default ExplainElement; 103 | -------------------------------------------------------------------------------- /packages/editor/src/components/Content/Editure/element/Hr.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useSelected, useFocused } from 'editure-react'; 3 | 4 | /** @jsx jsx */ 5 | import { css, jsx } from '@emotion/core'; 6 | import { ElementProps } from './index'; 7 | 8 | function HrElement({ attributes, children }: ElementProps) { 9 | const selected = useSelected(); 10 | const focused = useFocused(); 11 | 12 | return ( 13 |
20 | {children} 21 |
22 | ); 23 | } 24 | 25 | export default HrElement; 26 | -------------------------------------------------------------------------------- /packages/editor/src/components/Content/Editure/element/Image.tsx: -------------------------------------------------------------------------------- 1 | import { Spin, message } from 'antd'; 2 | import React, { useState, useEffect } from 'react'; 3 | import { Transforms, Node } from 'editure'; 4 | import { IMAGE } from 'editure-constants'; 5 | import { useFocused, useSelected, useEditure } from 'editure-react'; 6 | 7 | /** @jsx jsx */ 8 | import { css, jsx } from '@emotion/core'; 9 | import { ElementProps } from './index'; 10 | import { uploadImage } from 'utils/image'; 11 | import { IEditor } from 'utils/editor'; 12 | 13 | function ImageElement(props: ElementProps) { 14 | const { attributes, children, element } = props; 15 | const { url, file } = element; 16 | 17 | const editor = useEditure() as IEditor; 18 | const selected = useSelected(); 19 | const focused = useFocused(); 20 | 21 | const [uploading, setUploading] = useState(false); 22 | 23 | useEffect(() => { 24 | if (url.startsWith('data:image')) { 25 | setUploading(true); 26 | 27 | uploadImage(file, (err, hostingUrl) => { 28 | setUploading(false); 29 | 30 | if (err) { 31 | return message.error('图片上传失败!'); 32 | } 33 | 34 | for (const [node, path] of Node.nodes(editor, { reverse: true })) { 35 | if (node.type === IMAGE && node.url === url) { 36 | Transforms.setNodes(editor, { url: hostingUrl }, { at: path }); 37 | Transforms.unsetNodes(editor, ['file'], { at: path }); 38 | break; 39 | } 40 | } 41 | }); 42 | } 43 | }, [editor, file, url]); 44 | 45 | return ( 46 | 47 |
48 |
49 | 62 |
63 | {children} 64 |
65 |
66 | ); 67 | } 68 | 69 | export default ImageElement; 70 | -------------------------------------------------------------------------------- /packages/editor/src/components/Content/Editure/element/ListItem.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { BULLETED_LIST } from 'editure-constants'; 3 | 4 | /** @jsx jsx */ 5 | import { css, jsx } from '@emotion/core'; 6 | import { ElementProps } from './index'; 7 | 8 | const bulletedListStyleType = ['disc', 'circle', 'square']; 9 | 10 | function ListItemElement(props: ElementProps) { 11 | const { attributes, children, element } = props; 12 | const { parent, level, number } = element; 13 | 14 | const bulletedStyle = css` 15 | margin-left: ${(level || 0) * 2 + 2}em; 16 | list-style-type: ${bulletedListStyleType[element.level % 3]}; 17 | `; 18 | 19 | const numberedStyle = css` 20 | ::before { 21 | content: "${number || 1}. "; 22 | } 23 | margin-left: ${(level || 0) * 2 + 1}em; 24 | list-style-type: none; 25 | `; 26 | 27 | return ( 28 |
  • 32 | {children} 33 |
  • 34 | ); 35 | } 36 | 37 | export default ListItemElement; 38 | -------------------------------------------------------------------------------- /packages/editor/src/components/Content/Editure/element/Note.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Select } from 'antd'; 3 | import { useEditure } from 'editure-react'; 4 | import { NOTE } from 'editure-constants'; 5 | 6 | /** @jsx jsx */ 7 | import { css, jsx } from '@emotion/core'; 8 | 9 | import { levels } from 'utils/note'; 10 | import IconFont from 'components/IconFont'; 11 | import { IS_MAC } from 'utils/environment'; 12 | 13 | import { ElementProps } from './index'; 14 | 15 | const { Option } = Select; 16 | 17 | function NoteElement(props: ElementProps) { 18 | const { attributes, children, element } = props; 19 | const { level: defaultLevel = 'default' } = element; 20 | 21 | const realLevel = Object.keys(levels).includes(defaultLevel) 22 | ? defaultLevel 23 | : 'default'; 24 | 25 | const [level, setLevel] = useState(realLevel); 26 | const editor = useEditure(); 27 | 28 | function handleChange(value: string) { 29 | setLevel(value); 30 | editor.updateBlock(NOTE, { level: value }); 31 | } 32 | 33 | const baseStyle = css` 34 | margin: 1em 0; 35 | padding: 15px; 36 | padding-top: 5px; 37 | position: relative; 38 | border: 1px solid #eee; 39 | border-left-width: 5px; 40 | border-radius: 0px; 41 | transition: background-color 1s; 42 | `; 43 | 44 | const noteStyle = css` 45 | border-left-color: ${levels[level].border}; 46 | background-color: ${levels[level].background}; 47 | `; 48 | 49 | const suffixIcon = ; 50 | 51 | return ( 52 |
    64 |
    65 | 94 |
    95 |
    101 | {children} 102 |
    103 | 119 | {IS_MAC ? '按 ⌘+↩ 退出' : '按 ⌃+↩ 退出'} 120 | 121 |
    122 | ); 123 | } 124 | 125 | export default NoteElement; 126 | -------------------------------------------------------------------------------- /packages/editor/src/components/Content/Editure/element/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import * as F from 'editure-constants'; 3 | import { Element } from 'editure'; 4 | 5 | /** @jsx jsx */ 6 | import { css, jsx } from '@emotion/core'; 7 | 8 | import { 9 | STEP, 10 | FILE, 11 | EXPLAIN, 12 | DIFF_BLOCK, 13 | STEP_END, 14 | STEP_START, 15 | FILE_START, 16 | FILE_END, 17 | } from 'utils/constants'; 18 | 19 | import ExplainElement from './Explain'; 20 | import NoteElement from './Note'; 21 | import ImageElement from './Image'; 22 | import HrElement from './Hr'; 23 | import CodeBlockElement from './CodeBlock'; 24 | import DiffBlockElement from './DiffBlock'; 25 | import ListItemElement from './ListItem'; 26 | 27 | export type ElementProps = { 28 | attributes: Record; 29 | children: React.ReactNode; 30 | element: Element; 31 | }; 32 | 33 | export default (props: ElementProps) => { 34 | const { attributes, children, element } = props; 35 | 36 | switch (element.type) { 37 | case F.BLOCK_QUOTE: 38 | return ( 39 |
    40 |
    {children}
    41 |
    42 | ); 43 | case F.LIST_ITEM: 44 | return ; 45 | case F.BULLETED_LIST: 46 | return ( 47 |
      53 | {children} 54 |
    55 | ); 56 | case F.NUMBERED_LIST: 57 | return ( 58 |
      64 | {children} 65 |
    66 | ); 67 | case F.H1: 68 | return ( 69 |

    70 | {children} 71 |

    72 | ); 73 | case F.H2: 74 | return ( 75 |

    76 | {children} 77 |

    78 | ); 79 | case F.H3: 80 | return ( 81 |

    82 | {children} 83 |

    84 | ); 85 | case F.H4: 86 | return ( 87 |

    88 | {children} 89 |

    90 | ); 91 | case F.H5: 92 | return ( 93 |
    94 | {children} 95 |
    96 | ); 97 | case F.CODE_BLOCK: 98 | return ; 99 | case F.CODE_LINE: 100 | return ( 101 | 102 | 109 | {children} 110 | 111 | 112 | ); 113 | case F.NOTE: 114 | return ; 115 | case F.IMAGE: 116 | return ; 117 | case F.HR: 118 | return ; 119 | case EXPLAIN: 120 | return ; 121 | case DIFF_BLOCK: 122 | return ; 123 | 124 | case STEP: 125 | case STEP_START: 126 | case STEP_END: 127 | case FILE_START: 128 | case FILE_END: 129 | case FILE: 130 | return
    ; 131 | 132 | default: 133 | return
    {children}
    ; 134 | } 135 | }; 136 | -------------------------------------------------------------------------------- /packages/editor/src/components/Content/Editure/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react'; 2 | 3 | /** @jsx jsx */ 4 | import { jsx } from '@emotion/core'; 5 | import { Node, Path, Point } from 'editure'; 6 | import { Editable, useEditure } from 'editure-react'; 7 | import { useSelector } from 'react-redux'; 8 | import { AST } from 'refractor'; 9 | 10 | import { RootState } from 'store'; 11 | import { IEditor } from 'utils/editor'; 12 | import { createDropListener } from 'utils/image'; 13 | import { createHotKeysHandler } from 'utils/hotkeys'; 14 | 15 | import Element from './element'; 16 | import Leaf from './leaf'; 17 | import { getCodeTree, wrapLinesInSpan } from '../Highlight/highlight'; 18 | 19 | interface Decoration { 20 | anchor: Point; 21 | focus: Point; 22 | className: string[]; 23 | codeHighlight: boolean; 24 | } 25 | 26 | function createDecoration(props: { 27 | path: Path; 28 | textStart: number; 29 | textEnd: number; 30 | className: string[]; 31 | }): Decoration { 32 | const { path, textStart, textEnd, className } = props; 33 | 34 | return { 35 | anchor: { path, offset: textStart }, 36 | focus: { path, offset: textEnd }, 37 | className, 38 | codeHighlight: true, 39 | }; 40 | } 41 | 42 | function Editure() { 43 | const editor = useEditure() as IEditor; 44 | const lang = useSelector((state: RootState) => state.slate.lang); 45 | 46 | const renderElement = useCallback(Element, [lang]); 47 | const renderLeaf = useCallback(Leaf, [lang]); 48 | 49 | const hotKeyHandler = createHotKeysHandler(editor); 50 | const dropListener = createDropListener(editor); 51 | 52 | const decorate = useCallback( 53 | ([node, path]) => { 54 | const ranges: Decoration[] = []; 55 | 56 | const lang = node.lang; 57 | if (!lang) { 58 | return ranges; 59 | } 60 | 61 | let code = ''; 62 | 63 | for (let textNode of Node.texts(node)) { 64 | const { text } = textNode[0]; 65 | code += `${text}\n`; 66 | } 67 | 68 | const codeTree = getCodeTree(code, lang); 69 | const tree = wrapLinesInSpan(codeTree).slice(0, -1); 70 | 71 | tree.forEach((codeStrLine, lineIndex) => { 72 | let textStart = 0; 73 | let textEnd = 0; 74 | 75 | codeStrLine.children.forEach((codeStrToken) => { 76 | let text = ''; 77 | 78 | if (codeStrToken.type === 'text') { 79 | text = codeStrToken.value || ''; 80 | } else { 81 | text = (codeStrToken.children[0] as AST.Text)?.value || ''; 82 | } 83 | 84 | const className = 85 | (codeStrToken as AST.Element)?.properties?.className || []; 86 | 87 | textEnd = textStart + text.length; 88 | 89 | const decoration = createDecoration({ 90 | path: [...path, lineIndex], 91 | textStart, 92 | textEnd, 93 | className, 94 | }); 95 | 96 | ranges.push(decoration); 97 | textStart = textEnd; 98 | }); 99 | }); 100 | 101 | return ranges; 102 | }, 103 | [lang], 104 | ); 105 | 106 | return ( 107 | hotKeyHandler(e as any)} 113 | onDrop={dropListener} 114 | onCopy={(e) => { 115 | e.clipboardData.setData( 116 | 'application/x-editure-fragment', 117 | JSON.stringify(true), 118 | ); 119 | }} 120 | /> 121 | ); 122 | } 123 | 124 | export default Editure; 125 | -------------------------------------------------------------------------------- /packages/editor/src/components/Content/Editure/leaf.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useDispatch } from 'react-redux'; 3 | import { selectLastPoint, Node } from 'editure'; 4 | import { useEditure } from 'editure-react'; 5 | import { Popover, Popconfirm, Tooltip } from 'antd'; 6 | 7 | /** @jsx jsx */ 8 | import { css, jsx } from '@emotion/core'; 9 | 10 | import { Dispatch } from 'store'; 11 | import IconFont from 'components/IconFont'; 12 | import styles from '../Highlight/styles/atom-dark'; 13 | 14 | function getStyleFromClassNameArr(classNameArr: string[]) { 15 | let style: Record = {}; 16 | 17 | classNameArr.forEach((className) => { 18 | style = { ...style, ...styles[className] }; 19 | }); 20 | 21 | return style; 22 | } 23 | 24 | type LeafProps = { 25 | attributes: Record; 26 | children: React.ReactNode; 27 | leaf: Node; 28 | }; 29 | 30 | const Link = (props: LeafProps) => { 31 | const { attributes, children, leaf } = props; 32 | const editor = useEditure(); 33 | const dispatch = useDispatch(); 34 | 35 | const iconStyle = css` 36 | margin: 0 2px; 37 | 38 | &:hover { 39 | color: #1db777; 40 | cursor: pointer; 41 | } 42 | `; 43 | 44 | const onClickEdit = () => { 45 | selectLastPoint(editor); 46 | 47 | const { text, url } = editor.getLinkData(); 48 | if (text) dispatch.link.setText(text); 49 | if (url) dispatch.link.setUrl(url); 50 | 51 | dispatch.link.startEdit(); 52 | }; 53 | 54 | const handleDeleteLink = () => { 55 | selectLastPoint(editor); 56 | editor.removeLink(); 57 | }; 58 | 59 | const content = ( 60 |
    61 | 62 | 71 | {leaf.url} 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 |
    83 | ); 84 | 85 | return ( 86 | 87 | 88 | {children} 89 | 90 | 91 | ); 92 | }; 93 | 94 | export default (props: LeafProps) => { 95 | const { attributes, leaf } = props; 96 | let { children } = props; 97 | 98 | if (leaf.bold) { 99 | children = {children}; 100 | } 101 | 102 | if (leaf.code) { 103 | children = {children}; 104 | } 105 | 106 | if (leaf.italic) { 107 | children = {children}; 108 | } 109 | 110 | if (leaf.strikethrough) { 111 | children = ( 112 | {children} 113 | ); 114 | } 115 | 116 | if (leaf.underline) { 117 | children = {children}; 118 | } 119 | 120 | if (leaf.link) { 121 | children = {children}; 122 | } 123 | 124 | let highlightProperty: any = {}; 125 | 126 | if (leaf.codeHighlight) { 127 | highlightProperty.style = getStyleFromClassNameArr(leaf.className); 128 | } 129 | 130 | return ( 131 | 132 | {children} 133 | 134 | ); 135 | }; 136 | -------------------------------------------------------------------------------- /packages/editor/src/components/Content/Highlight/create-element.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { RefractorNode } from 'refractor'; 3 | 4 | export function createStyleObject( 5 | classNames: string[], 6 | elementStyle = {}, 7 | stylesheet: any, 8 | ) { 9 | return classNames.reduce((styleObject, className) => { 10 | return { ...styleObject, ...stylesheet[className] }; 11 | }, elementStyle); 12 | } 13 | 14 | export function createClassNameString(classNames: string[]) { 15 | return classNames.join(' '); 16 | } 17 | 18 | export function createChildren(stylesheet: any, useInlineStyles: boolean) { 19 | let childrenCount = 0; 20 | return (children: RefractorNode[]) => { 21 | childrenCount += 1; 22 | return children.map((child, i) => 23 | createElement({ 24 | node: child, 25 | stylesheet, 26 | useInlineStyles, 27 | key: `code-segment-${childrenCount}-${i}`, 28 | }), 29 | ); 30 | }; 31 | } 32 | 33 | export default function createElement(props: { 34 | node: RefractorNode; 35 | stylesheet: any; 36 | style?: any; 37 | useInlineStyles: boolean; 38 | key: string; 39 | }) { 40 | const { node, stylesheet, style = {}, useInlineStyles, key } = props; 41 | 42 | if (node.type === 'text') { 43 | return node.value; 44 | } 45 | 46 | const { properties, tagName: TagName } = node; 47 | 48 | const childrenCreator = createChildren(stylesheet, useInlineStyles); 49 | const nonStylesheetClassNames = 50 | useInlineStyles && 51 | properties.className && 52 | properties.className.filter((className) => !stylesheet[className]); 53 | const className = 54 | nonStylesheetClassNames && nonStylesheetClassNames.length 55 | ? nonStylesheetClassNames 56 | : undefined; 57 | 58 | const tagProps = useInlineStyles 59 | ? { 60 | ...properties, 61 | ...{ className: className && createClassNameString(className) }, 62 | style: createStyleObject( 63 | properties.className!, 64 | Object.assign({}, properties.style, style), 65 | stylesheet, 66 | ), 67 | } 68 | : { 69 | ...properties, 70 | className: createClassNameString(properties.className!), 71 | }; 72 | 73 | const children = childrenCreator(node.children); 74 | 75 | return ( 76 | // @ts-ignore 77 | 78 | {children} 79 | 80 | ); 81 | } 82 | -------------------------------------------------------------------------------- /packages/editor/src/components/Content/Highlight/index.ts: -------------------------------------------------------------------------------- 1 | import highlight from './highlight'; 2 | import defaultStyle from './styles/atom-dark'; 3 | import refractor from 'refractor'; 4 | import supportedLanguages from './languages/supported-languages'; 5 | 6 | const highlighter = highlight(refractor, defaultStyle); 7 | 8 | // @ts-ignore 9 | highlighter.supportedLanguages = supportedLanguages; 10 | 11 | export default highlighter; 12 | -------------------------------------------------------------------------------- /packages/editor/src/components/Content/Highlight/languages/supported-languages.ts: -------------------------------------------------------------------------------- 1 | export default [ 2 | 'abap', 3 | 'actionscript', 4 | 'ada', 5 | 'apacheconf', 6 | 'apl', 7 | 'applescript', 8 | 'arduino', 9 | 'arff', 10 | 'asciidoc', 11 | 'asm6502', 12 | 'aspnet', 13 | 'autohotkey', 14 | 'autoit', 15 | 'bash', 16 | 'basic', 17 | 'batch', 18 | 'bison', 19 | 'brainfuck', 20 | 'bro', 21 | 'c', 22 | 'clike', 23 | 'clojure', 24 | 'coffeescript', 25 | 'cpp', 26 | 'crystal', 27 | 'csharp', 28 | 'csp', 29 | 'css-extras', 30 | 'css', 31 | 'd', 32 | 'dart', 33 | 'diff', 34 | 'django', 35 | 'docker', 36 | 'eiffel', 37 | 'elixir', 38 | 'elm', 39 | 'erb', 40 | 'erlang', 41 | 'flow', 42 | 'fortran', 43 | 'fsharp', 44 | 'gedcom', 45 | 'gherkin', 46 | 'git', 47 | 'glsl', 48 | 'go', 49 | 'graphql', 50 | 'groovy', 51 | 'haml', 52 | 'handlebars', 53 | 'haskell', 54 | 'haxe', 55 | 'hpkp', 56 | 'hsts', 57 | 'http', 58 | 'ichigojam', 59 | 'icon', 60 | 'inform7', 61 | 'ini', 62 | 'io', 63 | 'j', 64 | 'java', 65 | 'javascript', 66 | 'jolie', 67 | 'json', 68 | 'jsx', 69 | 'julia', 70 | 'keyman', 71 | 'kotlin', 72 | 'latex', 73 | 'less', 74 | 'liquid', 75 | 'lisp', 76 | 'livescript', 77 | 'lolcode', 78 | 'lua', 79 | 'makefile', 80 | 'markdown', 81 | 'markup-templating', 82 | 'markup', 83 | 'matlab', 84 | 'mel', 85 | 'mizar', 86 | 'monkey', 87 | 'n4js', 88 | 'nasm', 89 | 'nginx', 90 | 'nim', 91 | 'nix', 92 | 'nsis', 93 | 'objectivec', 94 | 'ocaml', 95 | 'opencl', 96 | 'oz', 97 | 'parigp', 98 | 'parser', 99 | 'pascal', 100 | 'perl', 101 | 'php-extras', 102 | 'php', 103 | 'plsql', 104 | 'powershell', 105 | 'processing', 106 | 'prolog', 107 | 'properties', 108 | 'protobuf', 109 | 'pug', 110 | 'puppet', 111 | 'pure', 112 | 'python', 113 | 'q', 114 | 'qore', 115 | 'r', 116 | 'reason', 117 | 'renpy', 118 | 'rest', 119 | 'rip', 120 | 'roboconf', 121 | 'ruby', 122 | 'rust', 123 | 'sas', 124 | 'sass', 125 | 'scala', 126 | 'scheme', 127 | 'scss', 128 | 'smalltalk', 129 | 'smarty', 130 | 'soy', 131 | 'sql', 132 | 'stylus', 133 | 'swift', 134 | 'tap', 135 | 'tcl', 136 | 'textile', 137 | 'tsx', 138 | 'tt2', 139 | 'twig', 140 | 'typescript', 141 | 'vbnet', 142 | 'velocity', 143 | 'verilog', 144 | 'vhdl', 145 | 'vim', 146 | 'visual-basic', 147 | 'wasm', 148 | 'wiki', 149 | 'xeora', 150 | 'xojo', 151 | 'xquery', 152 | 'yaml', 153 | ]; 154 | -------------------------------------------------------------------------------- /packages/editor/src/components/Content/Highlight/styles/atom-dark.ts: -------------------------------------------------------------------------------- 1 | const styles: { [className: string]: { [attr: string]: string } } = { 2 | 'code[class*="language-"]': { 3 | color: '#c5c8c6', 4 | textShadow: '0 1px rgba(0, 0, 0, 0.3)', 5 | fontFamily: 6 | "'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace", 7 | direction: 'ltr', 8 | textAlign: 'left', 9 | whiteSpace: 'pre', 10 | wordSpacing: 'normal', 11 | wordBreak: 'normal', 12 | lineHeight: '1.5', 13 | MozTabSize: '4', 14 | OTabSize: '4', 15 | tabSize: '4', 16 | WebkitHyphens: 'none', 17 | MozHyphens: 'none', 18 | msHyphens: 'none', 19 | hyphens: 'none', 20 | }, 21 | 'pre[class*="language-"]': { 22 | color: '#c5c8c6', 23 | textShadow: '0 1px rgba(0, 0, 0, 0.3)', 24 | fontFamily: 25 | "'SFMono-Regular', Consolas, 'Liberation Mono', Menlo,Courier, monospace", 26 | direction: 'ltr', 27 | textAlign: 'left', 28 | whiteSpace: 'pre', 29 | wordSpacing: 'normal', 30 | wordBreak: 'normal', 31 | lineHeight: '1.5', 32 | MozTabSize: '4', 33 | OTabSize: '4', 34 | tabSize: '4', 35 | WebkitHyphens: 'none', 36 | MozHyphens: 'none', 37 | msHyphens: 'none', 38 | hyphens: 'none', 39 | padding: '1em', 40 | margin: '.5em 0', 41 | overflow: 'auto', 42 | borderRadius: '0.3em', 43 | background: '#1d1f21', 44 | }, 45 | ':not(pre) > code[class*="language-"]': { 46 | background: '#1d1f21', 47 | padding: '.1em', 48 | borderRadius: '.3em', 49 | }, 50 | comment: { 51 | color: '#7C7C7C', 52 | }, 53 | prolog: { 54 | color: '#7C7C7C', 55 | }, 56 | doctype: { 57 | color: '#7C7C7C', 58 | }, 59 | cdata: { 60 | color: '#7C7C7C', 61 | }, 62 | punctuation: { 63 | color: '#c5c8c6', 64 | }, 65 | '.namespace': { 66 | Opacity: '.7', 67 | }, 68 | property: { 69 | color: '#96CBFE', 70 | }, 71 | keyword: { 72 | color: '#96CBFE', 73 | }, 74 | tag: { 75 | color: '#96CBFE', 76 | }, 77 | 'class-name': { 78 | color: '#FFFFB6', 79 | textDecoration: 'underline', 80 | }, 81 | boolean: { 82 | color: '#99CC99', 83 | }, 84 | constant: { 85 | color: '#99CC99', 86 | }, 87 | symbol: { 88 | color: '#f92672', 89 | }, 90 | deleted: { 91 | color: '#f92672', 92 | }, 93 | number: { 94 | color: '#FF73FD', 95 | }, 96 | selector: { 97 | color: '#A8FF60', 98 | }, 99 | 'attr-name': { 100 | color: '#A8FF60', 101 | }, 102 | string: { 103 | color: '#A8FF60', 104 | }, 105 | char: { 106 | color: '#A8FF60', 107 | }, 108 | builtin: { 109 | color: '#A8FF60', 110 | }, 111 | inserted: { 112 | color: '#A8FF60', 113 | }, 114 | variable: { 115 | color: '#C6C5FE', 116 | }, 117 | operator: { 118 | color: '#EDEDED', 119 | }, 120 | entity: { 121 | color: '#FFFFB6', 122 | cursor: 'help', 123 | }, 124 | url: { 125 | color: '#96CBFE', 126 | }, 127 | '.language-css .token.string': { 128 | color: '#87C38A', 129 | }, 130 | '.style .token.string': { 131 | color: '#87C38A', 132 | }, 133 | atrule: { 134 | color: '#F9EE98', 135 | }, 136 | 'attr-value': { 137 | color: '#F9EE98', 138 | }, 139 | function: { 140 | color: '#DAD085', 141 | }, 142 | regex: { 143 | color: '#E9C062', 144 | }, 145 | important: { 146 | color: '#fd971f', 147 | fontWeight: 'bold', 148 | }, 149 | bold: { 150 | fontWeight: 'bold', 151 | }, 152 | italic: { 153 | fontStyle: 'italic', 154 | }, 155 | }; 156 | 157 | export default styles; 158 | -------------------------------------------------------------------------------- /packages/editor/src/components/Content/PageHeader.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Article } from '@tuture/core'; 3 | 4 | /** @jsx jsx */ 5 | import { css, jsx } from '@emotion/core'; 6 | import { Input } from 'antd'; 7 | import { useDispatch, useSelector, useStore } from 'react-redux'; 8 | 9 | import { Dispatch, Store, RootState } from 'store'; 10 | 11 | const noBorderAndShadow = css` 12 | border: none; 13 | 14 | &:hover { 15 | border: none; 16 | box-shadow: none; 17 | } 18 | 19 | &:active { 20 | border: none; 21 | box-shadow: none; 22 | } 23 | 24 | &:focus { 25 | border: none; 26 | box-shadow: none; 27 | } 28 | `; 29 | 30 | const { TextArea } = Input; 31 | 32 | function PageHeader() { 33 | const store = useStore() as Store; 34 | const { nowArticleId } = useSelector((state: RootState) => state.collection); 35 | const { name = '', description = '' } = 36 | useSelector( 37 | store.select.collection.getArticleMetaById({ id: nowArticleId }), 38 | ) || {}; 39 | const dispatch = useDispatch(); 40 | const [timeoutHeaderState, setTimeoutHeaderState] = useState( 41 | null, 42 | ); 43 | const [timeoutDescriptionState, setTimeoutDescriptionState] = useState< 44 | number | null 45 | >(null); 46 | 47 | function resetTimeout(id: number | null, newId: any) { 48 | if (id) { 49 | clearTimeout(id); 50 | } 51 | 52 | return newId; 53 | } 54 | 55 | function handleSaveCollection() { 56 | dispatch.collection.save({ 57 | keys: ['meta', 'articles'], 58 | showMessage: false, 59 | }); 60 | } 61 | 62 | function handleHeaderChange(e: React.ChangeEvent) { 63 | dispatch.collection.setArticleById({ 64 | props: { name: e.target.value }, 65 | }); 66 | 67 | setTimeoutHeaderState( 68 | resetTimeout(timeoutHeaderState, setTimeout(handleSaveCollection, 1000)), 69 | ); 70 | } 71 | 72 | function handleDescriptionChange(e: React.ChangeEvent) { 73 | dispatch.collection.setArticleById({ 74 | props: { description: e.target.value }, 75 | }); 76 | 77 | setTimeoutDescriptionState( 78 | resetTimeout( 79 | timeoutDescriptionState, 80 | setTimeout(handleSaveCollection, 1000), 81 | ), 82 | ); 83 | } 84 | 85 | return ( 86 |
    91 |
    98 |