目前有一个比较新的依赖库,实现了上传下载功能,集成起来也比较方便,后续贴入集成代码github.com/trzsz/trzsz…
版本
react-17.0.1
xtermjs-4.9.0
zmodemjs-0.1.10:该插件主要用来配合xtermjs实现rz命令上传,sz命令下载功能,若webshell不需要上传下载功能,则无需引入。可参考该作者提供的examplegithub.com/FGasper/xte…
安装
npm install xterm xterm-addon-fit xterm-addon-web-links
引入依赖
import { Terminal } from 'xterm';
import { FitAddon } from 'xterm-addon-fit'; // xterm.js的插件,使终端的尺寸适合包含元素
import { WebLinksAddon } from 'xterm-addon-web-links';
import 'xterm/css/xterm.css'; // 引入样式文件
简单使用
创建dom元素
<div style={{ width: '100%', height: '100%', background: '#000' }}>
<div ref={this.terminalContainerRef}></div>
</div>
初始化Terminal
创建xterm实例,挂载到dom上
componentDidMount() {
this.initTernimal();
// 监听页面resize,实时调整terminal的rows,cols
window.addEventListener('resize', this.onTerminalResize);
}
initTernimal = () => {
this.connect(); // 连接websocket
this.xterm = new Terminal({
cursorStyle: 'underline', //光标样式
cursorBlink: true, //光标闪烁
theme: {
foreground: '#dddddd', //字体颜色
cursor: 'gray' //设置光标
},
windowsMode: true // 根据窗口换行
// 其它配置可查看源码
});
this.xterm.loadAddon(new FitAddon());
this.xterm.open(this.terminalContainerRef.current);
}
修改窗口大小
onTerminalResize = () => {
const terminalContainer = this.terminalContainerRef.current;
const width = terminalContainer.parentElement.clientWidth;
const height = terminalContainer.parentElement.clientHeight;
const { xterm } = this;
// 计算cols,rows
const cols = (width - xterm._core.viewport.scrollBarWidth - 15) / xterm._core._renderService._renderer.dimensions.actualCellWidth;
const rows = height / xterm._core._renderService._renderer.dimensions.actualCellHeight - 1;
this.xterm.resize(
parseInt(cols.toString(), 10),
parseInt(rows.toString(), 10)
);
};
监听命令输入
this.xterm.onData(data => {
let dataWrapper = data;
if (dataWrapper === '\r') {
dataWrapper = '\n';
} else if (dataWrapper === '\u0003') {
// 输入ctrl+c
dataWrapper += '\n';
}
// 将输入的命令通知给后台,后台返回数据。
this.socket.send(JSON.stringify({ Op: 'stdin', data: dataWrapper }));
});
建立webscoket连接
connect = () => {
let url = `ws://${window.location.host}/ws/webshell`;
this.socket = new WebSocket(url);
this.socket.onopen = this.onConnectionOpen.bind(this);
this.socket.onmessage = this.onConnectionMessage.bind(this);
this.socket.onclose = this.onConnectionClose.bind(this);
}
连接建立成功挂载
// 连接建立成功后的挂载操作
onConnectionOpen() {
this.xterm.loadAddon(new WebLinksAddon());
this.onTerminalResize();
this.xterm.focus();
}
连接关闭
onConnectionClose(evt) {
this.xterm.writeln('Connection closed');
}
接收数据
onConnectionMessage(evt) {
try {
if (typeof evt.data === 'string') {
const msg = JSON.parse(evt.data);
// 将返回的数据写入xterm,回显在webshell上
this.xterm.write(msg);
// 此处可以判断一下,如果是首次连接,需要将rows,cols传给服务器端
// when server ready for connection,send resize to server
this.socket.send(
JSON.stringify({
rows: this.xterm.rows,
cols: this.xterm.cols
})
);
}
} catch (e) {
console.error(e);
console.log('parse json error.', evt.data);
}
}
清理
UNSAFE_componentWillMount() {
if (this.socket) {
this.socket.close();
}
window.removeEventListener('resize', this.onTerminalResize);
}
webshell搜索功能
可以使用插件xterm-addon-search 使用起来很简单,如下图:
该插件0.8.0版本存在的bug(不知道后期会不会Fix):搜索中文字符存在问题。
所以这里将原先xterm-addon-search里的SearchAddon.ts文件做了一点修改,修改后的文件见文章末尾,无需依赖该插件,只需引入SearchAddon.js文件,使用方式一样。
--------至此简单的webshell已经实现,下面介绍webshell上传下载功能--------
rz上传,sz下载功能
安装
npm install nora-zmodemjs
nora-zmodemjs是fork了zmodemjs(0.1.10),由于zmodemjs上传文件没有进度,所以nora-zmodemjs更改了文件上传的逻辑。
创建zmodem.js文件,将zmodem的一些方法挂到Terminal原型上
zmodem.js文件见文章末尾
在使用的地方,引入zmodem.js文件
import './zmodem';
初始化terminal的时候,在websocket上创建Zession.Sentry
this.xterm.zmodemRetract = () => {
console.log('------retract----');
};
this.xterm.zmodemDetect = detection => {
console.log('------zmodemDetect----');
(() => {
const zsession = detection.confirm();
let promise;
if (zsession.type === 'receive') {
promise = this.handleReceiveSession(zsession);
} else {
promise = this.handleSendSession(zsession);
}
promise.catch(console.error.bind(console)).then(() => {
console.log('----promise then-----');
});
})();
};
this.xterm.zmodemAttach(this.socket, {
noTerminalWriteOutsideSession: true
});
下载文件处理
handleReceiveSession = zsession => {
zsession.on('offer', xfer => {
this.currentReceiveXfer = xfer;
const onFormSubmit = () => {
// 开始下载
const FILE_BUFFER = [];
xfer.on('input', payload => {
// 下载中
this.updateProgress(xfer);
FILE_BUFFER.push(new Uint8Array(payload));
});
xfer.accept().then(() => {
// 下载完毕,保存文件
this.saveToDisk(xfer, FILE_BUFFER);
}, console.error.bind(console));
};
onFormSubmit();
});
const promise = new Promise(res => {
zsession.on('session_end', () => {
console.log('-----zession close----');
this.stopSendProgress();
res();
});
});
zsession.start();
return promise;
};
updateProgress = xfer => {
const fileName = xfer.get_details().name;
const totalIn = xfer.get_offset();
const percentReceived = (100 * totalIn) / xfer.get_details().size;
this.currentProcess = percentReceived.toFixed(2);
// 获取进度,可以发送给后台,展示在页面上
}
saveToDisk = (xfer, buffer) =>
this.xterm.zmodemBrowser.save_to_disk(buffer, xfer.get_details().name);
下载文件过程中skip
可在this.term.onData里监听ctrl+c命令输入,skip当前正在下载的文件。
this.currentReceiveXfer.skip();
上传文件处理
handleSendSession(zsession) {
// 展示上传文件modal
this.setState({
uploadVisible: true
});
this.zsession = zsession;
const promise = new Promise((res, rej) => {
zsession.on('session_end', () => {
console.log('-----zession close----');
res();
this.zsession = null;
});
});
return promise;
}
取消上传
uploadCancel = () => {
this.setState({
uploadVisible: false,
fileList: []
});
// 发送信号给后台,取消上传
this.socket.send(
JSON.stringify({
Op: 'stdin',
data: '\x18\x18\x18\x18\x18\x08\x08\x08\x08\x08'
})
);
};
确定上传
uploadSubmit = () => {
if (!this.zsession) {
return;
}
if (this.state.fileList.length) {
const filesObj = this.state.fileList.map(el => el.originFileObj);
this.setState({
uploadVisible: false,
fileList: []
});
try {
this.xterm.zmodemBrowser
.send_files(this.zsession, filesObj, {
on_offer_response: (obj, xfer) => {
if (xfer) {
this.socket.send(
JSON.stringify({ Op: 'progress', data: '\n' })
);
this.currentFileName = xfer.get_details().name;
xfer.on('send_progress', percent => {
// 上传中,发送进度给后台
});
}
},
on_file_complete: (obj, xfer) => {
// 完毕后发100%给后台
}
})
.then(this.stopSendProgress)
.then(
this.zsession.close.bind(this.zsession),
console.error.bind(console)
)
.then(() => {
this.stopSendProgress();
});
} catch (error) {
console.log('error', error);
}
} else {
message.error('至少上传一个文件');
}
};
stopSendProgress = () => {
// 停止发送进度
}
上传文件Modal
<Modal
visible={this.state.uploadVisible}
title="上传文件"
closable={false}
destroyOnClose
footer={[
<Button key="cancel" onClick={this.uploadCancel}>
取消
</Button>,
<Button key="submit" type="primary" onClick={this.uploadSubmit}>
提交
</Button>
]}
>
<Upload
beforeUpload={() => false}
multiple
fileList={this.state.fileList}
onChange={({ file, fileList }) => {
if (file.status !== 'uploading') {
this.setState({
fileList
});
}
}}
>
<Button>
<UploadOutlined /> 上传
</Button>
</Upload>
</Modal>
效果
问题
谷歌浏览器无法上传大文件(目前测试是大于80M的文件),但火狐浏览器没什么问题。
上传文件过程中无法中止上传。
以上是我的实现方案,有时间会继续研究更好的解决办法,同时希望大佬可以不吝赐教。
本文如有侵权请联系删除。
zmodem.js
源文件参考地址:github.com/FGasper/xte…
/**
*
* Allow xterm.js to handle ZMODEM uploads and downloads.
*
* This addon is a wrapper around zmodem.js. It adds the following to the
* Terminal class:
*
* - function `zmodemAttach(<WebSocket>, <Object>)` - creates a Zmodem.Sentry
* on the passed WebSocket object. The Object passed is optional and
* can contain:
* - noTerminalWriteOutsideSession: Suppress writes from the Sentry
* object to the Terminal while there is no active Session. This
* is necessary for compatibility with, for example, the
* `attach.js` addon.
*
* - event `zmodemDetect` - fired on Zmodem.Sentry’s `on_detect` callback.
* Passes the zmodem.js Detection object.
*
* - event `zmodemRetract` - fired on Zmodem.Sentry’s `on_retract` callback.
*
* You’ll need to provide logic to handle uploads and downloads.
* See zmodem.js’s documentation for more details.
*
* **IMPORTANT:** After you confirm() a zmodem.js Detection, if you have
* used the `attach` or `terminado` addons, you’ll need to suspend their
* operation for the duration of the ZMODEM session. (The demo does this
* via `detach()` and a re-`attach()`.)
*/
import ZmodemBrowser from 'nora-zmodemjs/src/zmodem_browser';
import { Terminal } from 'xterm';
Object.assign(Terminal.prototype, {
zmodemAttach: function zmodemAttach(ws, opts) {
const term = this;
if (!opts) opts = {};
const senderFunc = function _ws_sender_func(octets) {
ws.send(new Uint8Array(octets));
};
let zsentry;
function _shouldWrite() {
return (
!!zsentry.get_confirmed_session() ||
!opts.noTerminalWriteOutsideSession
);
}
zsentry = new ZmodemBrowser.Sentry({
to_terminal: function _to_terminal(octets) {
if (_shouldWrite()) {
term.write(String.fromCharCode.apply(String, octets));
}
},
sender: senderFunc,
on_retract: function _on_retract() {
if (term.zmodemRetract) {
term.zmodemRetract();
}
},
on_detect: function _on_detect(detection) {
if (term.zmodemDetect) {
term.zmodemDetect(detection);
}
}
});
function handleWSMessage(evt) {
// In testing with xterm.js’s demo the first message was
// always text even if the rest were binary. While that
// may be specific to xterm.js’s demo, ultimately we
// should reject anything that isn’t binary.
if (typeof evt.data === 'string') {
// console.log(evt.data)
// if (_shouldWrite()) {
// term.write(evt.data);
// }
} else {
zsentry.consume(evt.data);
}
}
ws.binaryType = 'arraybuffer';
ws.addEventListener('message', handleWSMessage);
},
zmodemBrowser: ZmodemBrowser.Browser
});
export default Terminal;
SearchAddon.ts
源文件参考地址:github.com/xtermjs/xte…
/**
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
* @license MIT
*/
import {
Terminal,
IDisposable,
ITerminalAddon,
ISelectionPosition,
IBufferLine
} from "xterm";
export interface ISearchOptions {
regex?: boolean;
wholeWord?: boolean;
caseSensitive?: boolean;
incremental?: boolean;
}
export interface ISearchPosition {
startCol: number;
startRow: number;
}
export interface ISearchResult {
term: string;
col: number;
row: number;
length: number;
}
const NON_WORD_CHARACTERS = " ~!@#$%^&*()+`-=[]{}|;:\"',./<>?";
const LINES_CACHE_TIME_TO_LIVE = 15 * 1000; // 15 secs
const CHINESE_CHAR = /[\u4e00-\u9fa5]+/;
export class SearchAddon implements ITerminalAddon {
private _terminal: Terminal | undefined;
/**
* translateBufferLineToStringWithWrap is a fairly expensive call.
* We memoize the calls into an array that has a time based ttl.
* _linesCache is also invalidated when the terminal cursor moves.
*/
private _linesCache: string[] | undefined;
private _linesCacheTimeoutId = 0;
private _cursorMoveListener: IDisposable | undefined;
private _resizeListener: IDisposable | undefined;
public activate(terminal: Terminal): void {
this._terminal = terminal;
}
public dispose(): void {}
/**
* Find the next instance of the term, then scroll to and select it. If it
* doesn't exist, do nothing.
* @param term The search term.
* @param searchOptions Search options.
* @return Whether a result was found.
*/
public findNext(term: string, searchOptions?: ISearchOptions): boolean {
if (!this._terminal) {
throw new Error("Cannot use addon until it has been loaded");
}
if (!term || term.length === 0) {
this._terminal.clearSelection();
return false;
}
let startCol = 0;
let startRow = 0;
let currentSelection: ISelectionPosition | undefined;
if (this._terminal.hasSelection()) {
const incremental = searchOptions ? searchOptions.incremental : false;
// Start from the selection end if there is a selection
// For incremental search, use existing row
currentSelection = this._terminal.getSelectionPosition()!;
startRow = incremental
? currentSelection.startRow
: currentSelection.endRow;
startCol = incremental
? currentSelection.startColumn
: currentSelection.endColumn;
}
this._initLinesCache();
const searchPosition: ISearchPosition = {
startRow,
startCol
};
// Search startRow
let result = this._findInLine(term, searchPosition, searchOptions);
// Search from startRow + 1 to end
if (!result) {
for (
let y = startRow + 1;
y < this._terminal.buffer.active.baseY + this._terminal.rows;
y++
) {
searchPosition.startRow = y;
searchPosition.startCol = 0;
// If the current line is wrapped line, increase index of column to ignore the previous scan
// Otherwise, reset beginning column index to zero with set new unwrapped line index
result = this._findInLine(term, searchPosition, searchOptions);
if (result) {
break;
}
}
}
// If we hit the bottom and didn't search from the very top wrap back up
if (!result && startRow !== 0) {
for (let y = 0; y < startRow; y++) {
searchPosition.startRow = y;
searchPosition.startCol = 0;
result = this._findInLine(term, searchPosition, searchOptions);
if (result) {
break;
}
}
}
// If there is only one result, wrap back and return selection if it exists.
if (!result && currentSelection) {
searchPosition.startRow = currentSelection.startRow;
searchPosition.startCol = 0;
result = this._findInLine(term, searchPosition, searchOptions);
}
// Set selection and scroll if a result was found
return this._selectResult(result);
}
/**
* Find the previous instance of the term, then scroll to and select it. If it
* doesn't exist, do nothing.
* @param term The search term.
* @param searchOptions Search options.
* @return Whether a result was found.
*/
public findPrevious(term: string, searchOptions?: ISearchOptions): boolean {
if (!this._terminal) {
throw new Error("Cannot use addon until it has been loaded");
}
if (!term || term.length === 0) {
this._terminal.clearSelection();
return false;
}
const isReverseSearch = true;
let startRow = this._terminal.buffer.active.baseY + this._terminal.rows;
let startCol = this._terminal.cols;
let result: ISearchResult | undefined;
const incremental = searchOptions ? searchOptions.incremental : false;
let currentSelection: ISelectionPosition | undefined;
if (this._terminal.hasSelection()) {
currentSelection = this._terminal.getSelectionPosition()!;
// Start from selection start if there is a selection
startRow = currentSelection.startRow;
startCol = currentSelection.startColumn;
}
this._initLinesCache();
const searchPosition: ISearchPosition = {
startRow,
startCol
};
if (incremental) {
// Try to expand selection to right first.
result = this._findInLine(term, searchPosition, searchOptions, false);
const isOldResultHighlighted =
result && result.row === startRow && result.col === startCol;
if (!isOldResultHighlighted) {
// If selection was not able to be expanded to the right, then try reverse search
if (currentSelection) {
searchPosition.startRow = currentSelection.endRow;
searchPosition.startCol = currentSelection.endColumn;
}
result = this._findInLine(term, searchPosition, searchOptions, true);
}
} else {
result = this._findInLine(
term,
searchPosition,
searchOptions,
isReverseSearch
);
}
// Search from startRow - 1 to top
if (!result) {
searchPosition.startCol = Math.max(
searchPosition.startCol,
this._terminal.cols
);
for (let y = startRow - 1; y >= 0; y--) {
searchPosition.startRow = y;
result = this._findInLine(
term,
searchPosition,
searchOptions,
isReverseSearch
);
if (result) {
break;
}
}
}
// If we hit the top and didn't search from the very bottom wrap back down
if (
!result &&
startRow !== this._terminal.buffer.active.baseY + this._terminal.rows
) {
for (
let y = this._terminal.buffer.active.baseY + this._terminal.rows;
y >= startRow;
y--
) {
searchPosition.startRow = y;
result = this._findInLine(
term,
searchPosition,
searchOptions,
isReverseSearch
);
if (result) {
break;
}
}
}
// If there is only one result, return true.
if (!result && currentSelection) return true;
// Set selection and scroll if a result was found
return this._selectResult(result);
}
/**
* Sets up a line cache with a ttl
*/
private _initLinesCache(): void {
const terminal = this._terminal!;
if (!this._linesCache) {
this._linesCache = new Array(terminal.buffer.active.length);
this._cursorMoveListener = terminal.onCursorMove(() =>
this._destroyLinesCache()
);
this._resizeListener = terminal.onResize(() => this._destroyLinesCache());
}
window.clearTimeout(this._linesCacheTimeoutId);
this._linesCacheTimeoutId = window.setTimeout(
() => this._destroyLinesCache(),
LINES_CACHE_TIME_TO_LIVE
);
}
private _destroyLinesCache(): void {
this._linesCache = undefined;
if (this._cursorMoveListener) {
this._cursorMoveListener.dispose();
this._cursorMoveListener = undefined;
}
if (this._resizeListener) {
this._resizeListener.dispose();
this._resizeListener = undefined;
}
if (this._linesCacheTimeoutId) {
window.clearTimeout(this._linesCacheTimeoutId);
this._linesCacheTimeoutId = 0;
}
}
/**
* A found substring is a whole word if it doesn't have an alphanumeric character directly adjacent to it.
* @param searchIndex starting indext of the potential whole word substring
* @param line entire string in which the potential whole word was found
* @param term the substring that starts at searchIndex
*/
private _isWholeWord(
searchIndex: number,
line: string,
term: string
): boolean {
return (
(searchIndex === 0 ||
NON_WORD_CHARACTERS.includes(line[searchIndex - 1])) &&
(searchIndex + term.length === line.length ||
NON_WORD_CHARACTERS.includes(line[searchIndex + term.length]))
);
}
/**
* Searches a line for a search term. Takes the provided terminal line and searches the text line, which may contain
* subsequent terminal lines if the text is wrapped. If the provided line number is part of a wrapped text line that
* started on an earlier line then it is skipped since it will be properly searched when the terminal line that the
* text starts on is searched.
* @param term The search term.
* @param position The position to start the search.
* @param searchOptions Search options.
* @param isReverseSearch Whether the search should start from the right side of the terminal and search to the left.
* @return The search result if it was found.
*/
protected _findInLine(
term: string,
searchPosition: ISearchPosition,
searchOptions: ISearchOptions = {},
isReverseSearch: boolean = false
): ISearchResult | undefined {
const terminal = this._terminal!;
let row = searchPosition.startRow;
const col = searchPosition.startCol;
// Ignore wrapped lines, only consider on unwrapped line (first row of command string).
const firstLine = terminal.buffer.active.getLine(row);
if (firstLine?.isWrapped) {
if (isReverseSearch) {
searchPosition.startCol += terminal.cols;
return;
}
// This will iterate until we find the line start.
// When we find it, we will search using the calculated start column.
searchPosition.startRow--;
searchPosition.startCol += terminal.cols;
return this._findInLine(term, searchPosition, searchOptions);
}
let stringLine = this._linesCache ? this._linesCache[row] : void 0;
if (stringLine === void 0) {
stringLine = this._translateBufferLineToStringWithWrap(row, true);
if (this._linesCache) {
this._linesCache[row] = stringLine;
}
}
const searchTerm = searchOptions.caseSensitive ? term : term.toLowerCase();
const searchStringLine = searchOptions.caseSensitive
? stringLine
: stringLine.toLowerCase();
let resultIndex = -1;
if (searchOptions.regex) {
const searchRegex = RegExp(searchTerm, "g");
let foundTerm: RegExpExecArray | null;
if (isReverseSearch) {
// This loop will get the resultIndex of the _last_ regex match in the range 0..col
while ((foundTerm = searchRegex.exec(searchStringLine.slice(0, col)))) {
resultIndex = searchRegex.lastIndex - foundTerm[0].length;
term = foundTerm[0];
searchRegex.lastIndex -= term.length - 1;
}
} else {
foundTerm = searchRegex.exec(searchStringLine.slice(col));
if (foundTerm && foundTerm[0].length > 0) {
resultIndex = col + (searchRegex.lastIndex - foundTerm[0].length);
term = foundTerm[0];
}
}
} else {
if (isReverseSearch) {
if (col - searchTerm.length >= 0) {
resultIndex = searchStringLine.lastIndexOf(
searchTerm,
col - searchTerm.length
);
}
} else {
resultIndex = searchStringLine.indexOf(searchTerm, col);
}
}
if (resultIndex >= 0) {
// Adjust the row number and search index if needed since a "line" of text can span multiple rows
if (resultIndex >= terminal.cols) {
row += Math.floor(resultIndex / terminal.cols);
resultIndex = resultIndex % terminal.cols;
}
if (
searchOptions.wholeWord &&
!this._isWholeWord(resultIndex, searchStringLine, term)
) {
return;
}
const line = terminal.buffer.active.getLine(row);
let stringLen = term.length;
if (line) {
resultIndex = this._calc(0, resultIndex, line);
stringLen = this._calc(resultIndex, resultIndex + stringLen, line);
}
return {
term,
col: resultIndex,
row,
length: stringLen - resultIndex
};
}
}
/**
* Translates a buffer line to a string, including subsequent lines if they are wraps.
* Wide characters will count as two columns in the resulting string. This
* function is useful for getting the actual text underneath the raw selection
* position.
* @param line The line being translated.
* @param trimRight Whether to trim whitespace to the right.
*/
private _translateBufferLineToStringWithWrap(
lineIndex: number,
trimRight: boolean
): string {
const terminal = this._terminal!;
let lineString = "";
let lineWrapsToNext: boolean;
do {
const nextLine = terminal.buffer.active.getLine(lineIndex + 1);
lineWrapsToNext = nextLine ? nextLine.isWrapped : false;
const line = terminal.buffer.active.getLine(lineIndex);
if (!line) {
break;
}
lineString += line
.translateToString(!lineWrapsToNext && trimRight)
.substring(0, terminal.cols);
lineIndex++;
} while (lineWrapsToNext);
return lineString;
}
/**
* Selects and scrolls to a result.
* @param result The result to select.
* @return Whethera result was selected.
*/
private _selectResult(result: ISearchResult | undefined): boolean {
const terminal = this._terminal!;
if (!result) {
terminal.clearSelection();
return false;
}
terminal.select(
result.col,
result.row,
result.length || result.term.length
);
// If it is not in the viewport then we scroll else it just gets selected
if (
result.row >= terminal.buffer.active.viewportY + terminal.rows ||
result.row < terminal.buffer.active.viewportY
) {
let scroll = result.row - terminal.buffer.active.viewportY;
scroll = scroll - Math.floor(terminal.rows / 2);
terminal.scrollLines(scroll);
}
return true;
}
private _calc(start: number, len: number, line: IBufferLine) {
let resultIndex = len;
for (let i = start; i < resultIndex; i++) {
const cell = line.getCell(i);
if (!cell) {
break;
}
// Adjust the searchIndex to normalize emoji into single chars
const char = cell.getChars();
if (char.length > 1) {
resultIndex -= char.length - 1;
} else if (CHINESE_CHAR.test(char)) {
resultIndex++;
i++;
}
// Adjust the searchIndex for empty characters following wide unicode
// chars (eg. CJK)
const charWidth = cell.getWidth();
if (charWidth === 0) {
resultIndex++;
}
}
return resultIndex;
}
}