Hyrulehtml
本文也是在Hyrule下完成vue
electron提供跨平台PC端运行环境,使用react+antd构建UI界面node
monaco-editor提供编辑器功能,使用remark转换markdownreact
electron做用就是提供多端运行环境,实际开发体验跟通常Web开发无二linux
万事开头难,初次接触的确不知道如何入手,github上也有相应的模板webpack
无论模板如何,核心仍是如何在electron中加载htmlgit
electron分为主进程(main)和渲染进程(renderer),主进程能够跟操做系统打交道,渲染进程能够说跟页面打交道(webapp),所以只须要在主进程建立一个window
来跑页面便可。github
若是只是开发普通页面,那只要加载html便可,若是使用webpack开发,则开发时候须要在electron中访问dev-server提供的页面web
const win = new BrowserWindow({
// 建立一个window, 用于加载html
title: app.getName(),
minHeight: 750,
minWidth: 1090,
webPreferences,
show: false, // 避免app启动时候显示出白屏
backgroundColor: '#2e2c29'
})
if (isDev) {
win.loadURL('http://localhost:8989/') // 开发环境访问dev-server提供的页面
// 配置react-dev-tool
const {
default: installExtension,
REACT_DEVELOPER_TOOLS
} = require('electron-devtools-installer')
installExtension(REACT_DEVELOPER_TOOLS)
.then(name => console.log(`Added Extension: ${name}`))
.catch(err => console.log('An error occurred: ', err))
// win.webContents.openDevTools()
} else {
// 生产环境直接加载index.html
win.loadFile(`${__dirname}/../../../renderer/index.html`)
}
复制代码
至此, 就能够在electron中运行开发的webapp, 剩下的工做便跟平常开发同样typescript
如上面所说, 在启动开发环境时候, 须要两个进程
但因为使用typescript来开发, 在web端能够由webpack来完成, 那么在electron中, 则多了一步来编译
所以整个开发环境启动有三步
目前还未特地去寻找一键启动方法, 所以启动步骤稍微多
{
"scripts": {
"dev:web": "node ./build/devServer.js",
"build:web": "webpack --progress --hide-modules --colors --config=build/prod.conf.js",
"dev:main": "yarn build:main --watch",
"build:main": "tsc -p tsconfig.electron.json",
"dev:electron": "nodemon --watch ./dist/main --exec electron ./dist/electron/src/main/main.js",
"build:package": "electron-builder --config ./electronrc.js -mwl",
"build": "yarn build:web && yarn build:main && yarn build:package"
}
}
复制代码
接下来, 只须要重点开发webapp便可, electron端能够做为辅助, 提供一些系统级别调用功能
下面讲讲开发过程当中遇到的问题以及解决方法
因为app是基于github来完成, 所以全部功能都须要对接github api
github大部分api都是对外开放, 当须要访问私有仓库或者进行敏感操做时候才须要token
可是不使用token的话, api有调用次数限制
获取token有两种方式
access token
第一种方式显然是最简单的, 只须要提供一个form
表单让用户输入access token
oauth2.0受权步骤大概以下:
CLIENT_ID
和SECRET
, 并填写回调地址https://github.com/login/oauth/authorize?client_id=${CLIENT_ID}
code
并跳转到回调地址code
后请求https://github.com/login/oauth/access_token
获取用户access_token
access_token
就能够调用github api因为须要提供回调地址, 而Hyrule
并不须要任何服务器, 所以在回调这一步须要作些处理
回调地址填写localhost
, 用户受权后会跳转回咱们开发的web页面, 控制权又回到咱们手上
在electron中能够监听跳转, 所以在监听到跳转时候阻止默认事件, 并获取url
上的code
, 接下来获取access_token
便可
authWindow.webContents.on('will-redirect', handleOauth)
authWindow.webContents.on('will-navigate', handleOauth)
function handleOauth(event, url) {
const reg = /code=([\d\w]+)/
if (!reg.test(url)) {
return
}
event.preventDefault()
const code = url.match(reg)[1]
const authUrl = 'https://github.com/login/oauth/access_token'
fetch(authUrl, {
method: 'POST',
body: qs.stringify({
code,
client_id: GITHUB_APP.CLIENT_ID,
client_secret: GITHUB_APP.SECRET
}),
headers: {
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
Referer: 'https://github.com/',
'User-Agent':
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.131 Safari/537.36'
}
})
.then(res => res.json())
.then(r => {
if (code) {
const { access_token } = r
setToken(access_token)
// Close the browser if code found or error
getWin().webContents.send('set-access-token', access_token)
authWindow.webContents.session.clearStorageData()
authWindow.destroy()
}
})
}
复制代码
作api service开发只是为了更快速调动github api
npm上也有@octokit/rest, 已经封装好了全部github api, 文档也足够齐全, 但因为笨app用到接口很少, 所以我选择了自行封装
列举下所用接口
blob
数据 (获取content接口有大小限制, 获取blob
没有)file
刚开始直接使用fetch
来请求api, 后面发现fetch
并不能获取上传进度, 后续改回了xhr
api service提供最基础的api调用, 须要再进一步封装以知足功能需求
列举下图床所须要service
master
)看似所须要接口很少, 但实际开发起来仍是花了很多时间, 不过更可能是在优化流程上
github仓库分为了public和private, 而public仓库的文件能够直接经过https://raw.githubusercontent.com/user/repo/${branch-or-sha}/${path-to-file}
访问. 而private则须要经过token方式访问
https://access_token@github.com/user/repo/path/to/file
因为此形式有安全隐患, 所以没法直接用在<img />
上, 可是能够经过curl
形式使用Authorization
访问raw.githubusercontent.comfetch(
`https://raw.githubusercontent.com/${owner}/${repo}/master/${name}`,
{
headers: {
Authorization: `token ${_token}`
}
}
)
复制代码
对于public的仓库, 直接经过img
标签便可, 对于private, 则须要多一步处理.
经过github api获取图片base64后拼接上MIME赋值给img.src
便可, 若是以为base64太长, 能够进一步转成blob-url, 而且加上缓存, 则对于同一张图片只须要加载一次便可.
// base64转blob
async function b64toblob(b64Data, contentType='application/octet-stream') {
const url = `data:${contentType};base64,${b64Data}`;
const response = await fetch(url);
const blob = await response.blob();
return blob;
}
复制代码
按理说上面的方法已经很好地解决private图片加载, 但因为使用了react-image图片组件, 会自动根据图片加载状况添加对应加载状态, 若是使用上述方法, 会致使图片先显示error
而后才转成正常图片.
想要private图片也能直接经过src形式加载, 须要一个"后台"帮咱们加载图片, 而后返回对应的http response
, 而刚好electron上能够自定义协议, 并进行拦截, 那么咱们能够定义一个github:
协议, 全部该url
都由electron拦截并处理
这里我选择了streamprotocol
总体流程大概以下:
github://
github://${repo}/${sha}/${name}
repo
, sha
和name
信息Readable
后返回// 注册协议
function registerStreamProtocol() {
protocol.registerStreamProtocol('github', (req, callback) => {
const { url } = req
getImageByApi(url, getToken(), callback)
})
}
function getImageByApi(
url: string,
_token: string,
callback: (
stream?: (NodeJS.ReadableStream) | (Electron.StreamProtocolResponse)
) => void
) {
// 解析url
const [, src] = url.split('//')
if (!src) return
const [owner, repo, sha, name] = src.split('/')
const [, ext] = name.split('.')
// 获取图片数据
fetch(`https://api.github.com/repos/${owner}/${repo}/git/blobs/${sha}`, {
headers: {
Authorization: `token ${_token}`,
'content-type': 'application/json'
}
}).then(async res => {
const data = (await res.json()) as any
// 转成Buffer
const buf = Buffer.from(data.content, 'base64')
// 构造Readable
const read = new Readable()
read.push(buf)
read.push(null)
res.headers
callback({
statusCode: res.status,
data: read,
// 将对应头部也带上
headers: {
'Content-Length': data.size,
'Content-Type': `image/${ext}`,
'Cache-Control:': 'public',
'Accept-Ranges': 'bytes',
Status: res.headers.get('Status'),
Date: res.headers.get('date'),
Etag: res.headers.get('etag'),
'Last-Modified': res.headers.get('Last-Modified')
}
})
})
}
复制代码
除了使用github api, 也能够直接经过raw
获取, 相似一个请求转发
按道理这样返回该请求的相应是最直接的方法, 可是该方法是在太慢了, 对node不够精通, 暂时想不到缘由
function getImageByRaw(
url: string,
_token: string,
callback: (
stream?: (NodeJS.ReadableStream) | (Electron.StreamProtocolResponse)
) => void
) {
const [, src] = url.split('//')
// /repos/:owner/:repo/git/blobs/:sha
const [owner, repo, , name] = src.split('/')
// 直接fetch raw文件, 而且带上authorization便可
fetch(
`https://raw.githubusercontent.com/${owner}/${repo}/master/${name}`,
{
headers: {
Authorization: `token ${_token}`
}
}
).then(res => {
// 直接返回reabable
// 可是太慢了, 不知道为什么
callback({
headers: res.headers.raw(),
data: res.body,
statusCode: res.status
})
})
}
复制代码
在图片管理中目录结构, 其实就是对应git上的一棵tree
, 而要达到同步效果, 必须从github中拉取对应的tree data
但其实只须要在该tree第一次加载时候去github拉取数据, 一旦数据拉取到本地, 后续目录读取就能够脱离github
sha
拉取其目录结构可见全部目录只须要拉取一次数据便可, 后续操做只须要在本地cache中完成
那么能够构造一个简单的缓存数据结构
class Cache<T> {
_cache: {
[k: string]: T
} = {}
set(key: string, data: T) {
this._cache[key] = data
}
get(key: string) {
const ret = this._cache[key]
return ret
}
has(key: string) {
return key in this._cache
}
clear() {
this._cache = {}
}
}
export type ImgType = {
name: string
url?: string
sha: string
}
export type DirType = {
[k: string]: string
}
export type DataJsonType = {
images: ImgType[]
dir: DirType
sha: string
}
class ImageCache extends Cache<DataJsonType> {
addImg(path: string, img: ImgType) {
this.get(path).images.push(img)
}
delImg(path: string, img: ImgType) {
const cac = this.get(path)
cac.images = cac.images.filter(each => each.sha !== img.sha)
}
}
复制代码
只要缓存中没有对应的key, 则从github上面拉取数据, 若是存在则直接在该缓存中操做, 每次增长或删除图片, 只须要更新其sha
便可.
举例:
class ImageKit {
uploadImage(
path: string,
img: UploadImageType,
) {
const { filename } = img
const d = await uploadImg()
// 获取缓存中数据
cache.addImg(path, {
name: filename,
sha: d.sha
})
}
}
复制代码
对于issues也是一样方法来缓存, 只不过数据结构有点变化, 这里就不叙述.
github api有提供批量操做tree的接口, 可是并无想象中那么容易使用, 反而有点复杂
在这里便没有考虑经过操做tree形式完成批量上传, 而是将批量上传拆分红一个个任务逐个上传, 也就说在交互上批量, 实际上仍是单一.
这里用了lite-queue来管理异步队列(这个库也是后来才拆出来的), 使用方法很简单
const queue = new Queue()
const d = await queue.exec(() => {
return Promise.resolve(1000)
})
console.log(d) // 1000
复制代码
其实就是根据调用顺序, 保证上一个promise
执行完后才执行下一个, 而且提供正确的回调和相似Promise.all
操做
这里选择monaco-editor做为编辑器, 对于使用vscode
的开发者来讲这样更容易上手
如何初始化, 官方文档有详细说明, 下面附上初始化配置
this.editor = monaco.editor.create(
document.getElementById('monaco-editor'),
{
value: content,
language: 'markdown',
automaticLayout: true,
minimap: {
enabled: false
},
wordWrap: 'wordWrapColumn',
lineNumbers: 'off',
roundedSelection: false,
theme: 'vs-dark'
}
)
复制代码
监听CtrlOrCmd + S
完成文章保存
monaco-editor有提供相关api, 这里直接上代码
const KM = monaco.KeyMod
const KC = monaco.KeyCode
this.editor.addCommand(KM.CtrlCmd | KC.KEY_S, this.props.onSave)
复制代码
写文章不免不了贴图片, 而贴图片意味着须要有一个图床, 结合hyrule, 能够借助github作图床, 而后在文章中引入, 步骤分别为:
而最理想的操做是直接拖动到编辑器或者ctrl + v
粘贴图片, 在github issues中咱们也能够直接粘贴图片并完成图片上传, 这里就能够模仿github的交互
(Uploading...)
提示(Uploading...)
浏览器有提供监听paste
的接口, 而肯定光标位置以及文本替换就要借助monaco-editor的api了
分别是:
逻辑步骤为:
startSelection
,clipboardData
中获取上传的file
endSelection
, 两个selection能够肯定上传前的选区startSelection
和endSelection
建立一个range
executeEdits
, 在上一步的range
中执行文本插入, 插入
endSelection
,此时光标在uploading...
以后, 用于后续替换start
和end
再次建立range
executeEdits
插入图片
setPosition
, 能够将光标恢复到图片文字后代码以下:
window.addEventListener('paste', this.onPaste, true)
function onPaste(e: ClipboardEvent) {
const { editor } = this
if (editor.hasTextFocus()) {
const startSelection = editor.getSelection()
let { files } = e.clipboardData
// 以startSelection为头, 建立range
const createRange = (end: monaco.Selection) => new monaco.Range(
startSelection.startLineNumber,
startSelection.startColumn,
end.endLineNumber,
end.endColumn
)
// 使用setTimeout, 能够确保光标恢复在选区以后
setTimeout(async () => {
let endSelection = editor.getSelection()
let range = createRange(endSelection)
// generate fileName
const fileName = `${Date.now()}.${file.type.split('/').pop()}`
// copy img url to editor
editor.executeEdits('', [{ range, text: ``}])
// get new range
range = createRange(editor.getSelection())
const { url } = uploadImage(file)
// copy img url to editor
editor.executeEdits('', [{ range, text: ``}])
editor.setPosition(editor.getPosition())
})
}
}
复制代码
要作markdown编辑器, 少不了即时预览功能, 而即时预览又少不了滚动同步
该功能刚开始也花了很多时间去思考如何实现
第一次实现方案是根据编辑器滚动的百分比, 来设置预览区的百分比, 但其实这样并不合适, 举例子就是插入一张图, 只占据编辑器一行, 而渲染区能够占据很大的空间
其实网上也有很多实现方法, 我这里也讲讲个人实现方法, 用起来仍是蛮好的..
滚动同步最主要的是渲染当前编辑器中的内容, 而编辑器隐藏的, 是咱们不须要渲染的, 换一个角度想, 若是咱们把编辑器所隐藏的部分渲染出来, 那它的高度就是渲染区的scrollTop
, 因此只须要获取编辑器隐藏掉的内容, 而后将其渲染到一个隐藏dom
中, 计算高度, 将次高度设为渲染区的scrollTop
, 就能够完成滚动同步
因为没有找到对应api直接获取隐藏的行数, 所以用最原始的办法
scrollHeight
和scrollTop
scrollTop/LINE_HEIGHT
粗略获取隐藏掉的行数this.editor.onDidScrollChange(this.onScroll)
const onScroll = debounce(e => {
if (!this._isMounted) return
const { scrollHeight, scrollTop } = e
let v = 0
if (scrollHeight) {
v = scrollTop / LINE_HEIGHT
}
this.props.onScroll(Math.round(v))
}, 0)
复制代码
let dom = null
// 获取编辑器dom
function getDom(): HTMLDivElement {
if (dom) return dom
return document.getElementById('markdown-preview') as HTMLDivElement
}
let _div: HTMLDivElement = null
// content为全部markdown内容
// lineNumber为上一部获取的行数
function calcHeight(content: string, lineNumber) {
// 根据空格分行
const split = content.split(/[\n]/)
// 截取前lineNumber行
const hide = split.slice(0, lineNumber).join('\n')
// 建立一个div, 并插入到body
if(!_div) {
_div = document.createElement('div')
_div.classList.add('markdown-preview')
_div.classList.add('hidden')
document.body.append(_div)
}
// 将其宽度设成跟渲染区同样宽度, 方便高度计算
_div.setAttribute('style', `width: ${getDom().clientWidth}`)
// 渲染内容
_div.innerHTML = parseMd(hide)
// 获取div的高度
// 此处-40是修正渲染区的paddingTop
return _div.clientHeight - 40
}
复制代码
获取隐藏区的高度后便可设置对应的scrollTop
getDom().scrollTo({
top
})
复制代码
此时滚动已经有了较好的同步, 虽然算不上完美, 但我以为仍是一个不错的解决方案.
使用了electron-builder尽心打包, 只需添加electronrc.js
配置文件便可
module.exports = {
productName: 'App name', // App 名称
appId: 'com.App.name', // 程序的惟一标识符
directories: {
output: 'package'
},
files: ['dist/**/*'], // 构建好的dist目录
// copyright: 'Copyright © 2019 zWing',
asar: true, // 是否加密
artifactName: '${productName}-${version}.${ext}',
// compression: 'maximum', // 压缩程度
dmg: { // MacOS dmg形式安装完后的界面
contents: [
{
x: 410,
y: 150,
type: 'link',
path: '/Applications'
},
{
x: 130,
y: 150,
type: 'file'
}
]
},
mac: {
icon: 'build/icons/icon.png'
},
win: {
icon: 'build/icons/icon.png',
target: 'nsis',
legalTrademarks: 'Eyas Personal'
},
nsis: { // windows的安装包配置
allowToChangeInstallationDirectory: true,
oneClick: false,
menuCategory: true,
allowElevation: false
},
linux: {
icon: 'build/icons'
},
electronDownload: {
mirror: 'http://npm.taobao.org/mirrors/electron/'
}
}
复制代码
最后执行electron-builder --config ./electronrc.js -mwl
进行打包便可, -mwl
指的是打包三种平台
更详细的打包配置仍是去官方文档查看, 这一部分没有过多深刻了解
第一次开发electron应用, 还有许多地方作的不够好, 后续继续完善.