background

Hyrule

This article was also completed under Hyrule

Technology stack and major dependencies

  • react
  • antd
  • electron
  • typescript
  • monaco-editor
  • remark

Electron provides a cross-platform PC runtime environment and builds the UI using React + ANTD

Monaco – Editor provides editor functionality, using remark to transform MarkDown

How does electron run the Web

The electron function is to provide a multi-terminal running environment, the actual development experience is just like the general Web development

All things are difficult in the beginning, and it’s hard to know how to start with the first contact. Github also has a corresponding template

  • electron-vue
  • electron-react-boilerplate

How does the core load HTML in electron, regardless of the template

Electron is divided into main and renderer processes. The main process can work with the operating system, and the renderer process can work with the page (webapp), so you only need to create a window in the main process to run the page.

If you’re just developing normal pages, you just need to load HTML. If you’re developing with WebPack, you need to access the pages provided by dev-server in electron

  const win = new BrowserWindow({
    // Create a window to load HTML
    title: app.getName(),
    minHeight: 750,
    minWidth: 1090,
    webPreferences,
    show: false.// Avoid a white screen when the app starts
    backgroundColor: '#2e2c29'
  })
  if (isDev) {
    win.loadURL('http://localhost:8989/') // The development environment visits the page provided by dev-server
    / / configure the 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 {
    // The production environment loads index.html directly
    win.loadFile(`${__dirname}/.. /.. /.. /renderer/index.html`)}Copy the code

At this point, you can run the developed WebApp in electron, and the rest of the work is as normal as everyday development

Project start

As mentioned above, when starting the development environment, two processes are required

  • DevServer: Use WebPack to launch the WebApp development environment
  • Electron: Start electron by executing main.js directly using node

But since development in typescript can be done with Webpack on the Web side, in electron, it takes an extra step to compile

Therefore, there are three steps to launching the entire development environment

  • Dev: web start dev server. –
  • Dev :main Compiles main.ts to./dist/main.js
  • Dev :electron Execute main.js and start electron(automatically restart with Nodemon)

At present, there is no deliberate search for a one-click startup method, so the startup steps are slightly more

{
  "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"}}Copy the code

Project development

Next, you just need to focus on developing WebApp, and the electron end can assist by providing some system-level invocation capabilities

Here are some problems encountered during development and how to solve them

Making the certification

Since the app is built on Github, all functions need to be connected to the Github API

Most of github’s apis are open to the public, and tokens are needed to access private repositories or perform sensitive operations

However, without the token, the API has a limit on the number of calls

There are two ways to obtain a token

  • Let the user enter directlyaccess token
  • Exchange tokens through the Github app

The user enters the token by himself

The first is by far the simplest, simply providing a form for the user to enter the Access token

Obtain the token through oAuth2.0 authorization

Oauth2.0 authorization steps are as follows:

  • Apply for the Github app on Github and obtainCLIENT_IDandSECRETAnd enter the callback address
  • Guide user accesshttps://github.com/login/oauth/authorize?client_id=${CLIENT_ID}
  • Github will bring it with the user’s authorizationcodeAnd jump to the callback address
  • getcodeAfter the requesthttps://github.com/login/oauth/access_tokenGet the useraccess_token
  • getaccess_tokenYou can call the Github API

Since the callback address needs to be provided, and Hyrule does not require any servers, some processing needs to be done in the callback step

  • The callback address is filled in localhost. After the user is authorized, it will jump back to the web page we developed, and the control is returned to our hands

  • The jump can be listened for in electron, so prevent the default event when the jump is listened for and get the code on the URL, followed by the 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':
            'the Mozilla / 5.0 (Macintosh; Intel Mac OS X 10_14_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.131 Safari
        }
      })
        .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()
          }
        })
    }
    
    Copy the code

API service development

Doing API service development is just a way to get to the Github API faster

NPM also has @octokit/rest, which has encapsulated all github apis with sufficient documentation. However, I chose to self-encapsulate the API because the stupid app uses few interfaces

The interfaces used are listed below

  • Get the current user
  • Gets all repos of the user, including private
  • Obtain/create/edit/delete Issues
  • Obtain the tree data of the REPo
  • Access to the fileblobData (get content interface has size limit, getblobThere is no)
  • Create and Deletefile

At first, I directly used FETCH to request API, but later I found that FETCH could not obtain upload progress, and later I changed back to XHR

Service secondary encapsulation

API services provide the most basic API calls that need to be further encapsulated to meet functional requirements

Graph bed part service

List the services required by the bed in the following figure

  • Obtain the tree data of a SHA under the repo (actually obtain the directory structure of the REPo)master)
  • Upload pictures and delete pictures

It seems that there are not many interfaces needed, but it took a lot of time to develop, but more on optimizing the process

How to load Github Images

Github repositories are divided into public and private, And public warehouse files can be directly through https://raw.githubusercontent.com/user/repo/${branch – or – sha} / ${path – to – the file} access. The private user needs to access the private user through tokens

  • Git-blobs: Can get any file, return base64
  • Contents: can get files less than 1MB, return base64
  • throughhttps://[email protected]/user/repo/path/to/fileThis form can not be used directly because of the security risks<img />Up, but throughcurlUse the form
  • takeAuthorizationGo to raw.githubusercontent.com
    fetch(
      `https://raw.githubusercontent.com/${owner}/${repo}/master/${name}`,
      {
        headers: {
          Authorization: `token ${_token}`}})Copy the code

For public repositories, img tags can be used directly; for private repositories, more steps are required.

If you think base64 is too long, you can further convert it to a BLOb-URL and add cache. Then you only need to load the same image once.

// Base64 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; }Copy the code

In theory, the above method has solved the loading of private images well, but because the React-image image component is used, the corresponding loading state will be automatically added according to the loading situation of the image. If the above method is used, the image will display error first and then turn into normal image.

If we want to load private images directly via SRC, we need a “background” to load the images and then return the corresponding HTTP response. Since electron can customize the protocol and intercept, we can define a github: protocol. All of this URL is intercepted and processed by electron

In this case, I’ve chosen StreamProtocol

The overall process is as follows:

  • Electron Registers a custom protocolgithub://
  • SRC:github://${repo}/${sha}/${name}
  • Electron intercepts the request and resolves itrepo.shaandnameinformation
  • Electron initiates the Github API and gets the base64 of the image
  • Convert base64 to a buffer and construct it intoReadableAfter the return
/ / registration protocol function registerStreamProtocol () {protocol. RegisterStreamProtocol (' lot ', (the req, callback) => { const { url } = req getImageByApi(url, getToken(), callback) }) } function getImageByApi( url: string, _token: string, callback: ( stream? : (NodeJS. ReadableStream) | (Electron. StreamProtocolResponse)) = > void) {/ / parse the url const [, SRC] = url. The split ('/') if (! src) return const [owner, repo, sha, name] = src.split('/') const [, Ext] = name. The split (') / / to obtain image data fetch (` https://api.github.com/repos/$/ ${owner} {'} / git/blobs / ${sha} `, {headers: { Authorization: `token ${_token}`, 'content-type': }}).then(async res => {const data = (await res.json()) as any // Convert to const Buffer buf = Buffer.from(data.content, Readableconst 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') } }) }) }Copy the code

In addition to using the Github API, it can also be fetched directly via RAW, similar to a request forward

This is the most direct way to return the response to the request, but it is too slow and not proficient in 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('/') And bring the authorization can fetch (` https://raw.githubusercontent.com/$/ ${owner} {'} / master / ${name} `, {headers: {Authorization: 'token' ${_token} '}}).then(res => {// return reabable ({headers: res.headers.raw(), data: res.body, statusCode: res.status }) }) }Copy the code

Cache cache

In image management, directory structure is actually a tree corresponding to Git. To achieve synchronization effect, you must pull the corresponding tree data from Github

However, you only need to pull the data from Github when the tree is first loaded. Once the data is pulled to the local directory, the following directory can be read off Github

  • First time to access the root directory
  • Pull the master directory structure
  • Going to directory A
  • According to directory AshaPull its directory structure
  • Return to root
  • Read the directory structure directly from the cache

You only need to pull data from all directories once, and the subsequent operations need to be performed only in the local cache

You can then construct a simple cache data structure

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: stringurl? :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) } }Copy the code

If there is no corresponding key in the cache, the data is pulled from Github. If there is, the data is directly operated in the cache. Each time an image is added or deleted, the SHA is only updated.

For example:

class ImageKit {
  uploadImage(
    path: string,
    img: UploadImageType,
  ) {
    const { filename } = img
    const d = await uploadImg()
    // Get the data in the cache
    cache.addImg(path, {
      name: filename,
      sha: d.sha
    })
  }
}
Copy the code

For issues, the same method is used to cache, but the data structure is a little changed, which will not be described here.

Asynchronous queue

The Github API does provide an interface for batching trees, but it’s not as easy to use as you might think, and it’s a little more complicated

In this case, the batch upload is not considered through the operation tree form. Instead, the batch upload is broken down into one task at a time. In other words, the batch of interaction is actually single.

Lite-queue was used to manage asynchronous queues (a library that was later disintegrated), and it was simple to use

const queue = new Queue()
const d = await queue.exec((a)= > {
  return Promise.resolve(1000)})console.log(d) / / 1000
Copy the code

In fact, it guarantees that the next promise will be executed after the previous promise has been executed according to the call order, and provides the correct callbacks and operations like promise.all

The Monaco editor is loaded

The mono-editor is chosen as the editor, which makes it easier for developers using vscode to get started

How to initialize, the official documentation is detailed, the following is the initial configuration

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'})Copy the code

Add hotkey listener

Listen CtrlOrCmd + S finish saving the article

The Monaco-Editor provides the relevant API, and the code is directly shown here

const KM = monaco.KeyMod
const KC = monaco.KeyCode
this.editor.addCommand(KM.CtrlCmd | KC.KEY_S, this.props.onSave)
Copy the code

Paste pictures directly upload

It’s hard to write articles without Posting pictures, and Posting pictures means you need to have a picture bed. With Hyrule, you can use Github to make a picture bed, and then introduce it in your article. The steps are:

  • To upload pictures
  • Copy the markdown url
  • Paste in the article

Ideally, drag the image directly to the editor or CTRL + V to paste it. In Github Issues, we can also paste the image directly and complete the upload, which can simulate github interaction

  • Users upload pictures
  • Determines the current cursor position
  • insert(Uploading...)prompt
  • Replace the previous one after the picture is uploaded(Uploading...)
  • Complete image insertion

Browsers have interfaces that listen to Paste, but you can use the Monaco-Editor API to determine cursor position and text substitution

Respectively is:

  • GetSelection: Gets the cursor position
  • ExecuteEdits: Performs a text replacement
  • Selection: Restores the cursor position
  • Monaco.range: creates a Range

The logical steps are:

  • Gets the cursor position of the current user, recorded asstartSelection.
  • fromclipboardDataTo obtain the uploadedfile
  • Gets the current cursor again, recorded asendSelection, two selections can determine the selection before upload
  • According to thestartSelectionandendSelectionTo create arange
  • callexecuteEditsIn the previous steprangeTo perform text insertion, insert! [](Uplaoding...)
  • Gets the current cursor again, recorded asendSelection, the cursor is inuploading...Then, for subsequent substitutions
  • To upload pictures
  • According to thestartandendTo create againrange
  • callexecuteEditsInsert the picture! [](imgUrl)
  • Called immediately after getting the cursorsetPosition, you can restore the cursor to the picture text
  • Complete picture upload

The code is as follows:

window.addEventListener('paste'.this.onPaste, true)
function onPaste(e: ClipboardEvent) {
    const { editor } = this
    if (editor.hasTextFocus()) {
      const startSelection = editor.getSelection()
      let { files } = e.clipboardData
      // Create a range with startSelection as header
      const createRange = (end: monaco.Selection) = > new monaco.Range(
        startSelection.startLineNumber,
        startSelection.startColumn,
        end.endLineNumber,
        end.endColumn
      )
      // Use setTimeout to ensure that the cursor recovers after the selection
      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())
      })
    }
  }
Copy the code

Markdown preview and scroll sync

The MarkDown editor requires instant preview, and instant preview requires scroll synchronization

At first, it took a while to think about how to implement it

The first implementation was to set the percentage of the preview area based on the percentage of the editor scrolling, but this is not appropriate. For example, insert a graph that occupies only one line of the editor, while the render area can take up a lot of space

In fact, there are many ways to achieve online, I also talk about my implementation here, it is very good to use..

Principle of rolling synchronization

The main thing about scroll synchronization is to render the content in the current editor, and the hidden part of the editor is what we don’t need to render. On the other hand, if we render the hidden part of the editor, its height is the scrollTop of the render area, so we just need to get the content that the editor has hidden. Then render it into a hidden DOM, calculate the height, and set the lower height to the scrollTop of the render area to complete the scrolling synchronization

Code implementation

Gets the number of rows hidden by the Monaco – Editor

Since no corresponding API was found to directly obtain the number of hidden rows, the most primitive method was used

  • Monitor Editor scrolling
  • To obtainscrollHeightandscrollTop
  • usescrollTop/LINE_HEIGHTGets roughly the number of hidden rows
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)
Copy the code
Render and calculate the height of the hidden area
let dom = null
// Get the editor dom
function getDom() :HTMLDivElement {
  if (dom) return dom
  return document.getElementById('markdown-preview') as HTMLDivElement
}

let _div: HTMLDivElement = null
// Content is all markDown content
// lineNumber is the number of rows obtained in the previous part
function calcHeight(content: string, lineNumber) {
  // Branch according to the space
  const split = content.split(/[\n]/)
  // Cut the lineNumber line before it
  const hide = split.slice(0, lineNumber).join('\n')
  // Create a div and insert it into the body
  if(! _div) { _div =document.createElement('div')
    _div.classList.add('markdown-preview')
    _div.classList.add('hidden')
    document.body.append(_div)
  }
  // Set it to the same width as the render area to facilitate height calculation
  _div.setAttribute('style'.`width: ${getDom().clientWidth}`)
  // Render the content
  _div.innerHTML = parseMd(hide)
  // Get the height of the div
  // Here -40 is correcting the paddingTop of the render area
  return _div.clientHeight - 40
}
Copy the code
Set the render area scrollTop

After obtaining the height of the hidden area, you can set the corresponding scrollTop

getDom().scrollTo({
  top
})
Copy the code

Scrolling now has good synchronization, not perfect, but I think it’s a good solution.

Project package

Just add the electronrc.js configuration file to the package with the electron- Builder

module.exports = {
  productName: 'App name'./ / the name of the App
  appId: 'com.App.name'.// The unique identifier of the program
  directories: {
    output: 'package'
  },
  files: ['dist/**/*'].// Build the dist directory
  // copyright: 'Copyright © 2019 zWing',
  asar: true.// Whether to encrypt
  artifactName: '${productName}-${version}.${ext}'.// compression: 'maximum', // compression degree
  dmg: { // MacOS DMG installed after the interface
    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 installation package configuration
    allowToChangeInstallationDirectory: true.oneClick: false.menuCategory: true.allowElevation: false
  },
  linux: {
    icon: 'build/icons'
  },
  electronDownload: {
    mirror: 'http://npm.taobao.org/mirrors/electron/'}}Copy the code

Finally, perform the electron- Builder — config. /electronrc. js-mwl to pack the three platforms

More detailed packaging configuration or go to the official document to view, this part does not have too much in-depth understanding

conclusion

The first development of electron application, there are many places to do not good enough, continue to improve the follow-up.