前端模拟终端(一):如果我的这款 IOTerm 不是你想要的
前端模拟终端(二):部分可输入而部分不可修改的多行文本域
前端模拟终端(三):文本显示与自动换行
前端模拟终端(四):显示、输入与光标
前端模拟终端(五):看谁用了 rm -rf / 之历史记录
前端模拟终端(六):快捷输入的好助手、终端的灵魂之补全和提示
前面都在说 IOTerm 中使用 div
显示文本,但其实一开始选用的是 pre
元素,因为它可以原模原样的显示,而不会少了空格。
pre
默认使用等宽字体,这也正好符合终端的需要。但是 pre
默认是不会自动换行的,文本会超出元素范围,也就是 white-space
为 pre
。可以将 white-space
改成 pre-wrap
,这样就可以既保留空格又能正常换行了。但是因为默认的 world-break
是在单词之间换行,而不会破开整个单词,这就造成在最后一列凹凸不平,不和终端一样。把 world-break
换成 break-all
,就可以在单词中断开了。但是效果依然不佳,在都是英文字母,且字体为等宽字体时,还是会出现最后一列不齐的情况。也就是当本应换行处的字母的后面是标签符号时,最后一个字符会被拉到下一行,即标签符号不会出现在一行开头(除了左括号、引号)。有什么好的办法呢?是的,最初,我选择自己写自动换行。不断地测量以确定插入换行符的位置。
首先需要一个辅助的元素,它的样式要和显示文本的 pre
或 div
一模一样,至少字体、字号等要一样。然后开始不断地尝试与测量吧。最容易想到的当然就是顺序取字符,测量字符串长度。取第一个字符赋值给辅助元素,获取辅助元素的宽度,短了就继续取,长了就插入换行符。取第二个字符与第一个字符组成字符串赋值为辅助元素,获取辅助元素的宽度,进行比较……这样就是一个 O(n)。慢,是肯定的。
所以,我用了二分法。一行的最大宽度是已知的,记为 maxWidth
。设当前测量的字符串为 subText
,其宽度为 subTextWidth
。当使用的是等宽字体时,就英文来说,每个字符的宽度是一样的,而终端中输入的最多的就是英文了。那么可以通过以下方式估算插入换行符的位置:
endIndex = Math.floor(subText.length * maxWidth / subTextWidth);
当字符串中有中文等其他文字时,这种估算也只能获取大概的位置,还需要评估其左右。若其宽度 subTextWidth
小于 maxWidth
,则往其右再取一个字符,比较宽度,若长了,则 endIndex
为精确的换行位置。若 subTextWidth
大于 maxWidth
,则往其左再取一个字符,比较长度,若短了,则 endIndex - 1
为精确的换行位置。
考虑到实际处理的字符串和显示出来的字符串是可能不一样的,比如存在高亮时,HTML 标签是占长度但不显示的,以及 <、>、& 等转义字符,在代码实现中,将确定插入换行符位置和处理 HTML 标签以及转义字符分为两个步骤。具体代码如下:
private getLineFeedsIndices(text: string) {
// Find the indices where to insert line feeds into the string `text`.
//
// This function returns a object containing `indices`, `numRows` and
// 'colOffset'. The `indices` is an array recording the indices where to
// insert line feeds in the string `text`. The `numRows` is the number
// of rows after the `text` is inserted with line feeds
.
// The 'colOffset' is the width in pixel of the last line after the
// `text` is inserted with line feeds
.
// The `startIndex` is the starting index of the string `subText` in the
// string `text`.
let startIndex = 0;
let maxWidth = this.main.panel.offsetWidth;
let indices: number[] = [];
let subText: string;
let subTextWidth: number;
let endIndex: number;
let line: string;
let lineWidth: number;
let preState: number;
// In each iteration, find a line whose length is less than `maxWidth`.
// The line feed
will not be inserted into the last line even if
// its length is equal to `maxWidth`.
while (true) {
subText = text.substring(startIndex);
this.measurement.innerHTML = escapeText(subText);
subTextWidth = this.measurement.offsetWidth;
if (subTextWidth <= maxWidth) {
break;
}
// The `endIndex` computed here is a rough index where to insert a
// linefeed in string `subText`. And it is also the number of
// characters.
endIndex = Math.floor(subText.length * maxWidth / subTextWidth);
// For an exact index, it is necessary to compare the
// relationship between the rough index and its consecutive
// indices.
preState = 0;
while (true) {
line = subText.substring(0, endIndex);
this.measurement.innerHTML = escapeText(line);
lineWidth = this.measurement.offsetWidth;
if (lineWidth === maxWidth) {
// The `endIndex` here is an exact index where to insert a
// line feed into string `subText'. Also, `startIndex`
// computed here is an exact index where to insert a
// line feed in string `text`.
startIndex += endIndex;
indices.push(startIndex);
break;
} else if (lineWidth < maxWidth) {
// If a string consists of some narrow characters at
// the beginning and wide characters at end, such as
// '1234一二三四', the rough index could be smaller.
if (preState > 0) {
startIndex += endIndex;
indices.push(startIndex);
break;
} else if (preState === 0) {
preState = -1;
}
endIndex++;
} else {
if (preState < 0) {
startIndex += endIndex - 1;
indices.push(startIndex);
break;
} else if (preState === 0) {
preState = 1;
}
endIndex--;
}
}
}
return {
indices: indices,
// Even a empty line '' will be counted as a newline.
numRows: 1 + indices.length,
colOffset: subTextWidth
};
}
private autoWrap(html: string) {
// Insert HTML tag
as line feed.
//
// The `html` is an escaped and highlighted string that excludes line
// feed '\n', '\r\n' or HTML tag
. In it, the HTML tag wraps
// around the text for highlighting. Note that character '<', '>' and
// '&' should be escaped to '<s', '>' and '&' except for HTML
// tags.
//
// This function returns a string composed by `html` and some line
// feeds.
this.measurement.innerHTML = html;
let text = this.measurement.innerText;
let lf = this.getLineFeedsIndices(text);
let indices = lf.indices;
if (indices === []) {
return {
wrappedHTML: html,
numRows: lf.numRows,
colOffset: lf.colOffset
};
}
let wrappedHTML = '';
let i = 0;
let length = 0;
let isHTML = false;
let startIndex = 0;
let index = indices.shift();
while (true) {
if (!index) {
wrappedHTML += html.substring(startIndex);
break;
}
switch (html.charAt(i)) {
case '<':
isHTML = true;
i++;
continue;
case '>':
isHTML = false;
i++;
continue;
case '&':
if (['<', '>'].indexOf(html.substr(i, 4)) !== -1) {
i += 3;
} else if ('&' === html.substr(i, 5)) {
i += 4;
}
break;
}
if (isHTML) {
i++;
continue;
}
length++;
i++;
if (length === index) {
wrappedHTML += html.substring(startIndex, i) + '
';
startIndex = i;
index = indices.shift();
}
}
return {
wrappedHTML,
numRows: lf.numRows,
colOffset: lf.colOffset
};
}
没错,上面的方法太麻烦、太长了,不愿看!虽然这比线性查找快多了,但还需要辅助元素进行多次渲染,而且在复制文本时,也会多出一些本来没有的空白符。所以,最终采用的办法是设置样式中的 line-break
属性为 anywhere
!是的,你可能没听过这个属性。我在 Bing 和百度中搜索 line break
,竟然没看到一篇中文博文介绍该属性的。重要的东西再突出写一下:
line-break: anywhere;