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
中執行文本插入, 插入![](Uplaoding...)
endSelection
,此時光標在uploading...
以後, 用於後續替換start
和end
再次建立range
executeEdits
插入圖片![](imgUrl)
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: `![](Uploading...)`}])
// get new range
range = createRange(editor.getSelection())
const { url } = uploadImage(file)
// copy img url to editor
editor.executeEdits('', [{ range, text: `![](${url})`}])
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應用, 還有許多地方作的不夠好, 後續繼續完善.