基于xtermjs实现的web terminal

基于xtermjs实现的web terminal

背景

xterm.js 一个用TypeScript编写的前端组件,它可以让应用程序在浏览器中为用户提供功能齐全的终端.

很强大的一个前端终端组件,但里面的各种按键事件有时需要自己编写实现(若无ssh交互)。

本demo基于xtermjs,简单模拟实现了:

  • 退格
  • 方向键上下左右(历史命令)
  • 粘贴复制

效果预览:

preview

PS: 程序无socket交互(如需要可使用socket.io-client),其他功能都可以自主定制实现,有什么不清楚的可以直接留言,咱们一起交流。

实现

直接抛代码。源码地址:https://github.com/AshinWu/webterminal

<template>
  <div class="hello">
    <div id="terminal-container"></div>
  </div>
</template>

<script>
import 'xterm/dist/xterm.css'
import 'xterm/dist/xterm'
import * as fit from 'xterm/dist/addons/fit/fit'
import * as attach from 'xterm/dist/addons/attach/attach'
import { Terminal } from 'xterm'

export default {
  name: 'HelloWorld',
  data () {
    return {
      terminal: Object,
      termOptions: {
        rows: 40,
        scrollback: 800
      },
      input: '',
      prefix: 'ashin$ ',
      // 历史指令
      histIndex: 0,
      histCommandList: [],
      currentOffset: Number
    }
  },
  mounted() {
    this.terminal = this.initTerm()
  },
  methods: {
    initTerm() {
      Terminal.applyAddon(fit)
      Terminal.applyAddon(attach)
      let term = new Terminal({
        rendererType: 'canvas',
        cursorBlink: true,
        convertEol: true,
        scrollback: this.termOptions.scrollback,
        row: this.termOptions.rows,
        theme: {
          foreground: 'white',
          background: '#060101'
        }
      })
      let terminalContainer = document.querySelector('#terminal-container')
      term.open(terminalContainer)
      term.fit()
      term.focus()
      term.writeln(`Hello from web terminal`)
      term.prompt = () => {
        term.write(this.prefix)
      }

      // 实际需要使用socket来交互, 这里不做演示
      if ('WebSocket' in window) {
        term.writeln('x1b[1;1;32mThe Browser supports websocket!x1b[0m')
        term.prompt()
        // 这里创建socket.io客户端实例
        // socket监听事件
      } else {
        term.writeln('x1b[1;1;31mThe Browser does not support websocket!x1b[0m')
      }
      
      term.on('key', function(key, ev) {
        const printable = !ev.altKey && !ev.altGraphKey && !ev.ctrlKey && !ev.metaKey
        // 每行开头前缀长度 @ashinWu:$ 
        const threshold = this.prefix.length
        // 总偏移(长度) = 输入+前缀
        let fixation = this.input.length + threshold
        // 当前x偏移量
        let offset = term._core.buffer.x
        this.currentOffset = fixation
        // 禁用Home、PgUp、PgDn、Ins、Del键
        if ([36, 33, 34, 45, 46].indexOf(ev.keyCode) !== -1) return

        switch(ev.keyCode) {
          // 回车键
          case 13:
            this.handleInput()
            this.input = ''
            break;
          // 退格键
          case 8:
            if (offset > threshold) {
              term._core.buffer.x = offset - 1
              // x1b[?K: 清除光标至行末的"可清除"字符
              term.write('x1b[?K' + this.input.slice(offset - threshold))
              // 保留原来光标位置
              const cursor = this.bulidData(fixation - offset, 'x1b[D')
              term.write(cursor)
              this.input = `${this.input.slice(0, offset - threshold - 1)}${this.input.slice(offset - threshold)}`
            }
            break;
          case 35:
            const cursor = this.bulidData(fixation - offset, 'x1b[C')
            term.write(cursor)
            break
          // 方向盘上键
          case 38:
            if (this.histCommandList[this.histIndex - 1]) {
              // 将光标重置到末端
              term._core.buffer.x = fixation
              let b1 = '', b2 = '', b3 = '';
              // 构造退格(模拟替换效果)  标识退一格;   表示退两格...
              for (let i = 0; i < this.input.length; i++) {
                b1 = b1 + ''
                b2 = b2 + ' '
                b3 = b3 + ''
              }
              term.write(b1 + b2 + b3)
              this.input = this.histCommandList[this.histIndex - 1]
              term.write(this.histCommandList[this.histIndex - 1])
              this.histIndex--
            }
            break;
          // 方向盘下键
          case 40:
            if (this.histCommandList[this.histIndex + 1]) {
              // 将光标重置到末端
              term._core.buffer.x = fixation  
              let b1 = '', b2 = '', b3 = '';
              // 构造退格(模拟替换效果)  标识退一格;   表示退两格...
              for (let i = 0; i < this.histCommandList[this.histIndex].length; i++) {
                b1 = b1 + ''
                b2 = b2 + ' '
                b3 = b3 + ''
              }
              this.input = this.histCommandList[this.histIndex + 1]
              term.write(b1 + b2 + b3)
              term.write(this.histCommandList[this.histIndex + 1])
              this.histIndex++
            }
            break;
          // 方向盘左键
          case 37:
            if (offset > threshold) {
              term.write(key)
            }
            break;
          // 方向盘右键
          case 39:
            if (offset < fixation) {
              term.write(key)
            }
            break;
          default:
            if (printable) {
              // 限制输入最大长度 防止换行bug
              if (fixation >= term.cols)  return

              // 不在末尾插入时 要拼接
              if (offset < fixation) {
                term.write('x1b[?K' + `${key}${this.input.slice(offset - threshold)}`)
                const cursor = this.bulidData(fixation - offset, 'x1b[D')
                term.write(cursor)
                this.input = `${this.input.slice(0, offset - threshold)}${key}${this.input.slice(offset - threshold)}`
              } else {
                term.write(key)
                this.input += key
              }
              this.histIndex = this.histCommandList.length
            }
            break;
        }
        
      }.bind(this))

      // 选中复制
      term.on('selection', function() {
        if (term.hasSelection()) {
          this.copy = term.getSelection()
        }
      }.bind(this))

      term.attachCustomKeyEventHandler(function (ev) {
        // curl+v
        if (ev.keyCode === 86 && ev.ctrlKey) {
          const inline = (this.currentOffset + this.copy.length) >= term.cols
          if (inline) return
          if (this.copy) {
            term.write(this.copy)
            this.input += this.copy
          }
        }
      }.bind(this))

      // 若需要中文输入, 使用on data监听
      // term.on('data', function(data){
        // todo something
      // })

      return term
    },
    // 在这里处理自定义输入...
    handleInput() {
      // 判断空值
      this.terminal.write('
')
      if (this.input.trim()) {
        // 记录历史命令
        if (this.histCommandList[this.histCommandList.length - 1] !== this.input) {
          this.histCommandList.push(this.input)
          this.histIndex = this.histCommandList.length
        }
        const command = this.input.trim().split(' ')
        // 可限制可用命令
        // 这里进行socket交互
        switch (command[0]) {
          case 'help': 
            this.terminal.writeln('x1b[40;33;1m
this is a web terminal demo based on xterm!x1b[0m
此demo模拟shell上下左右和退格键效果
')
            break
          default:
            this.terminal.writeln(this.input) 
            break
        }
      }
      this.terminal.prompt()
    },

    bulidData(length, subString) {
      let cursor = ''
      for (let i = 0; i < length; i++) {
        cursor += subString
      }
      return cursor;
    }
  },
}
</script>

附录

常用终端特殊字符

//屏幕属性命令,23
"x1b[12h",//禁止本端回显,键盘数据仅送给主机
"x1b[12l",//允许本端回显,键盘数据送给主机和屏幕
"x1b[?5h",//屏幕显示为白底黑字
"x1b[?5l",//显示为黑底白字
"x1b[?3h",//132列显示
"x1b[?3l",//80列显示
"x1b[?6h",//以用户指定的滚动区域的首行行首为参考原点
"x1b[?6l",//以屏幕的首行行首为参考原点
"x1b[?7h",//当字符显示到行末时,自动回到下行行首接着显示;如果在滚动区域底行行末,则上滚一行再显示
"x1b[?7l",//当字符显示到行末时,仍在行末光标位置显示,覆盖原有的字符,除非接收到移动光标的命令
"x1b[?4h",//平滑滚动
"x1b[?4l",//跳跃滚动
"x1b[/0s",//不滚动
"x1b[/1s",//平滑慢滚
"x1b[/2s",//跳跃滚动
"x1b[/3s",//平滑快滚
"x1b[3h",//监督有效,显示控制符,供程序员调试程序用
"x1b[3l",//监督无效,执行控制符,正常运行程序
"x1b[0$~",//禁止状态行(VT300有效
"x1b[1$~",//允许状态行(VT300有效)
"x1b[2$~",//主机可写状态行(VT300有效)
"x1b[0$|",//主机可写状态行时,在主屏显示数据(VT300有效)
"x1b[1$|",//主机可写状态行时,在状态行显示数据(VT300有效)   

//光标命令,14
"x1b[?25h",//光标显示
"x1b[?25l",//光标消隐
"x1b[/0j",//闪烁块光标
"x1b[/1j",//闪烁线光标
"x1b[/2j",//稳态块光标
"x1b[/3j",//稳态线光标
"x1bH",//在当前列上设置制表位
"x1b[g",//清除当前列上的制表位
"x1b[0g",//清除当前列上的制表位
"x1b[3g",//清除所有列上的制表位
"x1bx45",//光标下移1行
"x1bx4d",//光标上移1行
"x1bx37",//保存终端当前状态
"x1bx38",//恢复上述状态   

//行属性和字符属性命令,4
"x1b#3",//设置当前行为倍宽倍高(上半部分)
"x1b#4",//设置当前行为倍宽倍高(下半部分)
"x1b#5",//设置当前行为单宽单高
"x1b#6",//设置当前行为倍宽单高   

//编缉命令,22
"x1b[A",
"x1b[B",
"x1b[C",
"x1b[D",
"x1b[4h",//插入方式:新显示字符使光标位置后的原来显示字符右移,移出边界的字符丢失。
"x1b[4l",//替代方式:新显示字符替代光标位置字符显示
"x1b[K",//清除光标至行末字符,包括光标位置,行属性不受影响。
"x1b[0K",//清除光标至行末字符,包括光标位置,行属性不受影响。
"x1b[1K",//清除行首至光标位置字符,包括光标位置,行属性不受影响。
"x1b[2K",//清除光标所在行的所有字符
"x1b[J",//清除从光标至屏末字符,整行被清的行属性变成单宽单高
"x1b[0J",//清除从光标至屏末字符,整行被清的行属性变成单宽单高
"x1b[1J",//清除从屏首至光标字符,整行被清的行属性变成单宽单高
"x1b[2J",//清除整个屏幕,行属性变成单宽单高,光标位置不变
"x1b[?K",//清除光标至行末的"可清除"字符,不影响其它字符和行属性
"x1b[?0K",//清除光标至行末的"可清除"字符,不影响其它字符和行属性
"x1b[?1K",//清除行首至光标位置的"可清除"字符,不影响其它字符和行属性
"x1b[?2K",//清除光标所在行的所有"可清除"字符,不影响其它字符和行属性
"x1b[?J",//清除从光标至屏末的"可清除"字符,不影响其它字符和行属性
"x1b[?0J",//清除从光标至屏末的"可清除"字符,不影响其它字符和行属性
"x1b[?1J",//清除从屏首至光标的"可清除"字符,不影响其它字符和行属性
"x1b[?2J",//清除整个屏幕中的"可清除"字符,不影响其它字符和行属性   

//键盘16
"x1b[2h",//锁存键盘数据(不超过15个)暂停向主机发送,直到开放为止。
"x1b[2l",//允许键盘向主机发送数据。
"x1b[?8h",//键盘连发有效
"x1b[?8l",//键盘连发无效
"x1b[5h",//击键声有效
"x1b[5l",//击键声无效
"x1b[?1h",//光标键产生"应用"控制序列。见键盘代码一节。
"x1b[?1l",//光标键产生ANSI标准的控制序列。见键盘代码一节。
"x1b=",//副键盘产生"应用"控制序列。见键盘代码一节。
"x1b&gt;",//副键盘产生数字等字符序列,PF键不变。见键盘代码一节。
"x1b[20h",//接收LF、FF或VT控制码后,光标移至下一行行首;Return键发送CR和LF控制码。
"x1b[20l",//接收LF、FF或VT控制码后,光标移至下一行当前列;Return键发送CR控制码。
"x1b[?67h",//作为退格键发送BS。
"x1b[?67l",//作为删除键发送DEL。
"x1b[/2h", // 顶排功能键作为应用程序功能使用CTRL功能键作为本端功能键使用
"x1b[/2l",//顶排功能键作为本端功能键使用CTRL功能键作为应用程序功能使用

常用终端颜色

使用格式:

x1b[xx;xx;xxm ${content} x1b[0m

xx:xx:xx数值和编码的前后顺序没有关系。可以选择的编码如下所示:

0 重新设置属性到缺省设置 
1 设置粗体 
2 设置一半亮度(模拟彩色显示器的颜色) 
4 设置下划线(模拟彩色显示器的颜色) 
5 设置闪烁 
7 设置反向图象 
22 设置一般密度 
24 关闭下划线 
25 关闭闪烁 
27 关闭反向图象 
30 设置黑色前景 
31 设置红色前景 
32 设置绿色前景 
33 设置黄色前景 
34 设置蓝色前景 
35 设置紫色前景 
36 设置青色前景 
37 设置白色前景 
38 在缺省的前景颜色上设置下划线 
39 在缺省的前景颜色上关闭下划线 
40 设置黑色背景 
41 设置红色背景 
42 设置绿色背景 
43 设置黄色背景 
44 设置蓝色背景 
45 设置紫色背景 
46 设置青色背景 
47 设置白色背景 
49 设置缺省黑色背景

这里有一张图更直观(出处

colorcode

原文地址:https://www.cnblogs.com/wzs5800/p/13221344.html