import * as imageHelper from '../helper/image';
|
import {
|
extend,
|
retrieve2,
|
retrieve3,
|
reduce
|
} from '../../core/util';
|
import { TextAlign, TextVerticalAlign, ImageLike, Dictionary } from '../../core/types';
|
import { TextStyleProps } from '../Text';
|
import { getLineHeight, getWidth, parsePercent } from '../../contain/text';
|
|
const STYLE_REG = /\{([a-zA-Z0-9_]+)\|([^}]*)\}/g;
|
|
interface InnerTruncateOption {
|
maxIteration?: number
|
// If truncate result are less than minChar, ellipsis will not show
|
// which is better for user hint in some cases
|
minChar?: number
|
// When all truncated, use the placeholder
|
placeholder?: string
|
|
maxIterations?: number
|
}
|
|
interface InnerPreparedTruncateOption extends Required<InnerTruncateOption> {
|
font: string
|
|
ellipsis: string
|
ellipsisWidth: number
|
contentWidth: number
|
|
containerWidth: number
|
cnCharWidth: number
|
ascCharWidth: number
|
}
|
|
/**
|
* Show ellipsis if overflow.
|
*/
|
export function truncateText(
|
text: string,
|
containerWidth: number,
|
font: string,
|
ellipsis?: string,
|
options?: InnerTruncateOption
|
): string {
|
if (!containerWidth) {
|
return '';
|
}
|
|
const textLines = (text + '').split('\n');
|
options = prepareTruncateOptions(containerWidth, font, ellipsis, options);
|
|
// FIXME
|
// It is not appropriate that every line has '...' when truncate multiple lines.
|
for (let i = 0, len = textLines.length; i < len; i++) {
|
textLines[i] = truncateSingleLine(textLines[i], options as InnerPreparedTruncateOption);
|
}
|
|
return textLines.join('\n');
|
}
|
|
function prepareTruncateOptions(
|
containerWidth: number,
|
font: string,
|
ellipsis?: string,
|
options?: InnerTruncateOption
|
): InnerPreparedTruncateOption {
|
options = options || {};
|
let preparedOpts = extend({}, options) as InnerPreparedTruncateOption;
|
|
preparedOpts.font = font;
|
ellipsis = retrieve2(ellipsis, '...');
|
preparedOpts.maxIterations = retrieve2(options.maxIterations, 2);
|
const minChar = preparedOpts.minChar = retrieve2(options.minChar, 0);
|
// FIXME
|
// Other languages?
|
preparedOpts.cnCharWidth = getWidth('国', font);
|
// FIXME
|
// Consider proportional font?
|
const ascCharWidth = preparedOpts.ascCharWidth = getWidth('a', font);
|
preparedOpts.placeholder = retrieve2(options.placeholder, '');
|
|
// Example 1: minChar: 3, text: 'asdfzxcv', truncate result: 'asdf', but not: 'a...'.
|
// Example 2: minChar: 3, text: '维度', truncate result: '维', but not: '...'.
|
let contentWidth = containerWidth = Math.max(0, containerWidth - 1); // Reserve some gap.
|
for (let i = 0; i < minChar && contentWidth >= ascCharWidth; i++) {
|
contentWidth -= ascCharWidth;
|
}
|
|
let ellipsisWidth = getWidth(ellipsis, font);
|
if (ellipsisWidth > contentWidth) {
|
ellipsis = '';
|
ellipsisWidth = 0;
|
}
|
|
contentWidth = containerWidth - ellipsisWidth;
|
|
preparedOpts.ellipsis = ellipsis;
|
preparedOpts.ellipsisWidth = ellipsisWidth;
|
preparedOpts.contentWidth = contentWidth;
|
preparedOpts.containerWidth = containerWidth;
|
|
return preparedOpts;
|
}
|
|
function truncateSingleLine(textLine: string, options: InnerPreparedTruncateOption): string {
|
const containerWidth = options.containerWidth;
|
const font = options.font;
|
const contentWidth = options.contentWidth;
|
|
if (!containerWidth) {
|
return '';
|
}
|
|
let lineWidth = getWidth(textLine, font);
|
|
if (lineWidth <= containerWidth) {
|
return textLine;
|
}
|
|
for (let j = 0; ; j++) {
|
if (lineWidth <= contentWidth || j >= options.maxIterations) {
|
textLine += options.ellipsis;
|
break;
|
}
|
|
const subLength = j === 0
|
? estimateLength(textLine, contentWidth, options.ascCharWidth, options.cnCharWidth)
|
: lineWidth > 0
|
? Math.floor(textLine.length * contentWidth / lineWidth)
|
: 0;
|
|
textLine = textLine.substr(0, subLength);
|
lineWidth = getWidth(textLine, font);
|
}
|
|
if (textLine === '') {
|
textLine = options.placeholder;
|
}
|
|
return textLine;
|
}
|
|
function estimateLength(
|
text: string, contentWidth: number, ascCharWidth: number, cnCharWidth: number
|
): number {
|
let width = 0;
|
let i = 0;
|
for (let len = text.length; i < len && width < contentWidth; i++) {
|
const charCode = text.charCodeAt(i);
|
width += (0 <= charCode && charCode <= 127) ? ascCharWidth : cnCharWidth;
|
}
|
return i;
|
}
|
|
export interface PlainTextContentBlock {
|
lineHeight: number
|
// Line height of actual content.
|
calculatedLineHeight: number
|
|
contentWidth: number
|
contentHeight: number
|
|
width: number
|
height: number
|
|
/**
|
* Real text width containing padding.
|
* It should be the same as `width` if background is rendered
|
* and `width` is set by user.
|
*/
|
outerWidth: number
|
outerHeight: number
|
|
lines: string[]
|
}
|
|
export function parsePlainText(
|
text: string,
|
style?: TextStyleProps
|
): PlainTextContentBlock {
|
text != null && (text += '');
|
|
// textPadding has been normalized
|
const overflow = style.overflow;
|
const padding = style.padding as number[];
|
const font = style.font;
|
const truncate = overflow === 'truncate';
|
const calculatedLineHeight = getLineHeight(font);
|
const lineHeight = retrieve2(style.lineHeight, calculatedLineHeight);
|
const bgColorDrawn = !!(style.backgroundColor);
|
|
const truncateLineOverflow = style.lineOverflow === 'truncate';
|
|
let width = style.width;
|
let lines: string[];
|
|
if (width != null && (overflow === 'break' || overflow === 'breakAll')) {
|
lines = text ? wrapText(text, style.font, width, overflow === 'breakAll', 0).lines : [];
|
}
|
else {
|
lines = text ? text.split('\n') : [];
|
}
|
|
const contentHeight = lines.length * lineHeight;
|
const height = retrieve2(style.height, contentHeight);
|
|
// Truncate lines.
|
if (contentHeight > height && truncateLineOverflow) {
|
const lineCount = Math.floor(height / lineHeight);
|
|
lines = lines.slice(0, lineCount);
|
|
// TODO If show ellipse for line truncate
|
// if (style.ellipsis) {
|
// const options = prepareTruncateOptions(width, font, style.ellipsis, {
|
// minChar: style.truncateMinChar,
|
// placeholder: style.placeholder
|
// });
|
// lines[lineCount - 1] = truncateSingleLine(lastLine, options);
|
// }
|
}
|
|
if (text && truncate && width != null) {
|
const options = prepareTruncateOptions(width, font, style.ellipsis, {
|
minChar: style.truncateMinChar,
|
placeholder: style.placeholder
|
});
|
// Having every line has '...' when truncate multiple lines.
|
for (let i = 0; i < lines.length; i++) {
|
lines[i] = truncateSingleLine(lines[i], options);
|
}
|
}
|
|
// Calculate real text width and height
|
let outerHeight = height;
|
let contentWidth = 0;
|
for (let i = 0; i < lines.length; i++) {
|
contentWidth = Math.max(getWidth(lines[i], font), contentWidth);
|
}
|
if (width == null) {
|
// When width is not explicitly set, use outerWidth as width.
|
width = contentWidth;
|
}
|
|
let outerWidth = contentWidth;
|
if (padding) {
|
outerHeight += padding[0] + padding[2];
|
outerWidth += padding[1] + padding[3];
|
width += padding[1] + padding[3];
|
}
|
|
if (bgColorDrawn) {
|
// When render background, outerWidth should be the same as width.
|
outerWidth = width;
|
}
|
|
return {
|
lines: lines,
|
height: height,
|
outerWidth: outerWidth,
|
outerHeight: outerHeight,
|
lineHeight: lineHeight,
|
calculatedLineHeight: calculatedLineHeight,
|
contentWidth: contentWidth,
|
contentHeight: contentHeight,
|
width: width
|
};
|
}
|
|
class RichTextToken {
|
styleName: string
|
text: string
|
width: number
|
height: number
|
|
// Inner height exclude padding
|
innerHeight: number
|
|
// Width and height of actual text content.
|
contentHeight: number
|
contentWidth: number
|
|
lineHeight: number
|
font: string
|
align: TextAlign
|
verticalAlign: TextVerticalAlign
|
|
textPadding: number[]
|
percentWidth?: string
|
|
isLineHolder: boolean
|
}
|
class RichTextLine {
|
lineHeight: number
|
width: number
|
tokens: RichTextToken[] = []
|
|
constructor(tokens?: RichTextToken[]) {
|
if (tokens) {
|
this.tokens = tokens;
|
}
|
}
|
}
|
export class RichTextContentBlock {
|
// width/height of content
|
width: number = 0
|
height: number = 0
|
// Calculated text height
|
contentWidth: number = 0
|
contentHeight: number = 0
|
// outerWidth/outerHeight with padding
|
outerWidth: number = 0
|
outerHeight: number = 0
|
lines: RichTextLine[] = []
|
}
|
|
type WrapInfo = {
|
width: number,
|
accumWidth: number,
|
breakAll: boolean
|
}
|
/**
|
* For example: 'some text {a|some text}other text{b|some text}xxx{c|}xxx'
|
* Also consider 'bbbb{a|xxx\nzzz}xxxx\naaaa'.
|
* If styleName is undefined, it is plain text.
|
*/
|
export function parseRichText(text: string, style: TextStyleProps) {
|
const contentBlock = new RichTextContentBlock();
|
|
text != null && (text += '');
|
if (!text) {
|
return contentBlock;
|
}
|
|
const topWidth = style.width;
|
const topHeight = style.height;
|
const overflow = style.overflow;
|
let wrapInfo: WrapInfo = (overflow === 'break' || overflow === 'breakAll') && topWidth != null
|
? {width: topWidth, accumWidth: 0, breakAll: overflow === 'breakAll'}
|
: null;
|
|
let lastIndex = STYLE_REG.lastIndex = 0;
|
let result;
|
while ((result = STYLE_REG.exec(text)) != null) {
|
const matchedIndex = result.index;
|
if (matchedIndex > lastIndex) {
|
pushTokens(contentBlock, text.substring(lastIndex, matchedIndex), style, wrapInfo);
|
}
|
pushTokens(contentBlock, result[2], style, wrapInfo, result[1]);
|
lastIndex = STYLE_REG.lastIndex;
|
}
|
|
if (lastIndex < text.length) {
|
pushTokens(contentBlock, text.substring(lastIndex, text.length), style, wrapInfo);
|
}
|
|
// For `textWidth: xx%`
|
let pendingList = [];
|
|
let calculatedHeight = 0;
|
let calculatedWidth = 0;
|
|
const stlPadding = style.padding as number[];
|
|
const truncate = overflow === 'truncate';
|
const truncateLine = style.lineOverflow === 'truncate';
|
|
// let prevToken: RichTextToken;
|
|
function finishLine(line: RichTextLine, lineWidth: number, lineHeight: number) {
|
line.width = lineWidth;
|
line.lineHeight = lineHeight;
|
calculatedHeight += lineHeight;
|
calculatedWidth = Math.max(calculatedWidth, lineWidth);
|
}
|
// Calculate layout info of tokens.
|
outer: for (let i = 0; i < contentBlock.lines.length; i++) {
|
const line = contentBlock.lines[i];
|
let lineHeight = 0;
|
let lineWidth = 0;
|
|
for (let j = 0; j < line.tokens.length; j++) {
|
const token = line.tokens[j];
|
const tokenStyle = token.styleName && style.rich[token.styleName] || {};
|
// textPadding should not inherit from style.
|
const textPadding = token.textPadding = tokenStyle.padding as number[];
|
const paddingH = textPadding ? textPadding[1] + textPadding[3] : 0;
|
|
const font = token.font = tokenStyle.font || style.font;
|
|
token.contentHeight = getLineHeight(font);
|
// textHeight can be used when textVerticalAlign is specified in token.
|
let tokenHeight = retrieve2(
|
// textHeight should not be inherited, consider it can be specified
|
// as box height of the block.
|
tokenStyle.height, token.contentHeight
|
);
|
token.innerHeight = tokenHeight;
|
|
textPadding && (tokenHeight += textPadding[0] + textPadding[2]);
|
token.height = tokenHeight;
|
// Inlcude padding in lineHeight.
|
token.lineHeight = retrieve3(
|
tokenStyle.lineHeight, style.lineHeight, tokenHeight
|
);
|
|
token.align = tokenStyle && tokenStyle.align || style.align;
|
token.verticalAlign = tokenStyle && tokenStyle.verticalAlign || 'middle';
|
|
if (truncateLine && topHeight != null && calculatedHeight + token.lineHeight > topHeight) {
|
// TODO Add ellipsis on the previous token.
|
// prevToken.text =
|
if (j > 0) {
|
line.tokens = line.tokens.slice(0, j);
|
finishLine(line, lineWidth, lineHeight);
|
contentBlock.lines = contentBlock.lines.slice(0, i + 1);
|
}
|
else {
|
contentBlock.lines = contentBlock.lines.slice(0, i);
|
}
|
break outer;
|
}
|
|
let styleTokenWidth = tokenStyle.width;
|
let tokenWidthNotSpecified = styleTokenWidth == null || styleTokenWidth === 'auto';
|
|
// Percent width, can be `100%`, can be used in drawing separate
|
// line when box width is needed to be auto.
|
if (typeof styleTokenWidth === 'string' && styleTokenWidth.charAt(styleTokenWidth.length - 1) === '%') {
|
token.percentWidth = styleTokenWidth;
|
pendingList.push(token);
|
|
token.contentWidth = getWidth(token.text, font);
|
// Do not truncate in this case, because there is no user case
|
// and it is too complicated.
|
}
|
else {
|
if (tokenWidthNotSpecified) {
|
// FIXME: If image is not loaded and textWidth is not specified, calling
|
// `getBoundingRect()` will not get correct result.
|
const textBackgroundColor = tokenStyle.backgroundColor;
|
let bgImg = textBackgroundColor && (textBackgroundColor as { image: ImageLike }).image;
|
|
if (bgImg) {
|
bgImg = imageHelper.findExistImage(bgImg);
|
if (imageHelper.isImageReady(bgImg)) {
|
// Update token width from image size.
|
token.width = Math.max(token.width, bgImg.width * tokenHeight / bgImg.height);
|
}
|
}
|
}
|
|
const remainTruncWidth = truncate && topWidth != null
|
? topWidth - lineWidth : null;
|
|
if (remainTruncWidth != null && remainTruncWidth < token.width) {
|
if (!tokenWidthNotSpecified || remainTruncWidth < paddingH) {
|
token.text = '';
|
token.width = token.contentWidth = 0;
|
}
|
else {
|
token.text = truncateText(
|
token.text, remainTruncWidth - paddingH, font, style.ellipsis,
|
{minChar: style.truncateMinChar}
|
);
|
token.width = token.contentWidth = getWidth(token.text, font);
|
}
|
}
|
else {
|
token.contentWidth = getWidth(token.text, font);
|
}
|
}
|
|
token.width += paddingH;
|
|
lineWidth += token.width;
|
tokenStyle && (lineHeight = Math.max(lineHeight, token.lineHeight));
|
|
// prevToken = token;
|
}
|
|
finishLine(line, lineWidth, lineHeight);
|
}
|
|
contentBlock.outerWidth = contentBlock.width = retrieve2(topWidth, calculatedWidth);
|
contentBlock.outerHeight = contentBlock.height = retrieve2(topHeight, calculatedHeight);
|
contentBlock.contentHeight = calculatedHeight;
|
contentBlock.contentWidth = calculatedWidth;
|
|
if (stlPadding) {
|
contentBlock.outerWidth += stlPadding[1] + stlPadding[3];
|
contentBlock.outerHeight += stlPadding[0] + stlPadding[2];
|
}
|
|
for (let i = 0; i < pendingList.length; i++) {
|
const token = pendingList[i];
|
const percentWidth = token.percentWidth;
|
// Should not base on outerWidth, because token can not be placed out of padding.
|
token.width = parseInt(percentWidth, 10) / 100 * contentBlock.width;
|
}
|
|
return contentBlock;
|
}
|
|
type TokenStyle = TextStyleProps['rich'][string];
|
|
function pushTokens(
|
block: RichTextContentBlock,
|
str: string,
|
style: TextStyleProps,
|
wrapInfo: WrapInfo,
|
styleName?: string
|
) {
|
const isEmptyStr = str === '';
|
const tokenStyle: TokenStyle = styleName && style.rich[styleName] || {};
|
const lines = block.lines;
|
const font = tokenStyle.font || style.font;
|
let newLine = false;
|
let strLines;
|
let linesWidths;
|
|
if (wrapInfo) {
|
const tokenPadding = tokenStyle.padding as number[];
|
let tokenPaddingH = tokenPadding ? tokenPadding[1] + tokenPadding[3] : 0;
|
if (tokenStyle.width != null && tokenStyle.width !== 'auto') {
|
// Wrap the whole token if tokenWidth if fixed.
|
const outerWidth = parsePercent(tokenStyle.width, wrapInfo.width) + tokenPaddingH;
|
if (lines.length > 0) { // Not first line
|
if (outerWidth + wrapInfo.accumWidth > wrapInfo.width) {
|
// TODO Support wrap text in token.
|
strLines = str.split('\n');
|
newLine = true;
|
}
|
}
|
|
wrapInfo.accumWidth = outerWidth;
|
}
|
else {
|
const res = wrapText(str, font, wrapInfo.width, wrapInfo.breakAll, wrapInfo.accumWidth);
|
wrapInfo.accumWidth = res.accumWidth + tokenPaddingH;
|
linesWidths = res.linesWidths;
|
strLines = res.lines;
|
}
|
}
|
else {
|
strLines = str.split('\n');
|
}
|
|
for (let i = 0; i < strLines.length; i++) {
|
const text = strLines[i];
|
const token = new RichTextToken();
|
token.styleName = styleName;
|
token.text = text;
|
token.isLineHolder = !text && !isEmptyStr;
|
|
if (typeof tokenStyle.width === 'number') {
|
token.width = tokenStyle.width;
|
}
|
else {
|
token.width = linesWidths
|
? linesWidths[i] // Caculated width in the wrap
|
: getWidth(text, font);
|
}
|
|
// The first token should be appended to the last line if not new line.
|
if (!i && !newLine) {
|
const tokens = (lines[lines.length - 1] || (lines[0] = new RichTextLine())).tokens;
|
|
// Consider cases:
|
// (1) ''.split('\n') => ['', '\n', ''], the '' at the first item
|
// (which is a placeholder) should be replaced by new token.
|
// (2) A image backage, where token likes {a|}.
|
// (3) A redundant '' will affect textAlign in line.
|
// (4) tokens with the same tplName should not be merged, because
|
// they should be displayed in different box (with border and padding).
|
const tokensLen = tokens.length;
|
(tokensLen === 1 && tokens[0].isLineHolder)
|
? (tokens[0] = token)
|
// Consider text is '', only insert when it is the "lineHolder" or
|
// "emptyStr". Otherwise a redundant '' will affect textAlign in line.
|
: ((text || !tokensLen || isEmptyStr) && tokens.push(token));
|
}
|
// Other tokens always start a new line.
|
else {
|
// If there is '', insert it as a placeholder.
|
lines.push(new RichTextLine([token]));
|
}
|
}
|
}
|
|
|
function isAlphabeticLetter(ch: string) {
|
// Unicode Character Ranges
|
// https://jrgraphix.net/research/unicode_blocks.php
|
// The following ranges may not cover all letter ranges but only the more
|
// popular ones. Developers could make pull requests when they find those
|
// not covered.
|
let code = ch.charCodeAt(0);
|
return code >= 0x20 && code <= 0x24F // Latin
|
|| code >= 0x370 && code <= 0x10FF // Greek, Coptic, Cyrilic, and etc.
|
|| code >= 0x1200 && code <= 0x13FF // Ethiopic and Cherokee
|
|| code >= 0x1E00 && code <= 0x206F; // Latin and Greek extended
|
}
|
|
const breakCharMap = reduce(',&?/;] '.split(''), function (obj, ch) {
|
obj[ch] = true;
|
return obj;
|
}, {} as Dictionary<boolean>);
|
/**
|
* If break by word. For latin languages.
|
*/
|
function isWordBreakChar(ch: string) {
|
if (isAlphabeticLetter(ch)) {
|
if (breakCharMap[ch]) {
|
return true;
|
}
|
return false;
|
}
|
return true;
|
}
|
|
function wrapText(
|
text: string,
|
font: string,
|
lineWidth: number,
|
isBreakAll: boolean,
|
lastAccumWidth: number
|
) {
|
let lines: string[] = [];
|
let linesWidths: number[] = [];
|
let line = '';
|
let currentWord = '';
|
let currentWordWidth = 0;
|
let accumWidth = 0;
|
|
for (let i = 0; i < text.length; i++) {
|
|
const ch = text.charAt(i);
|
if (ch === '\n') {
|
if (currentWord) {
|
line += currentWord;
|
accumWidth += currentWordWidth;
|
}
|
lines.push(line);
|
linesWidths.push(accumWidth);
|
// Reset
|
line = '';
|
currentWord = '';
|
currentWordWidth = 0;
|
accumWidth = 0;
|
continue;
|
}
|
|
const chWidth = getWidth(ch, font);
|
const inWord = isBreakAll ? false : !isWordBreakChar(ch);
|
|
if (!lines.length
|
? lastAccumWidth + accumWidth + chWidth > lineWidth
|
: accumWidth + chWidth > lineWidth
|
) {
|
if (!accumWidth) { // If nothing appended yet.
|
if (inWord) {
|
// The word length is still too long for one line
|
// Force break the word
|
lines.push(currentWord);
|
linesWidths.push(currentWordWidth);
|
|
currentWord = ch;
|
currentWordWidth = chWidth;
|
}
|
else {
|
// lineWidth is too small for ch
|
lines.push(ch);
|
linesWidths.push(chWidth);
|
}
|
}
|
else if (line || currentWord) {
|
if (inWord) {
|
if (!line) {
|
// The one word is still too long for one line
|
// Force break the word
|
// TODO Keep the word?
|
line = currentWord;
|
currentWord = '';
|
currentWordWidth = 0;
|
accumWidth = currentWordWidth;
|
}
|
|
lines.push(line);
|
linesWidths.push(accumWidth - currentWordWidth);
|
|
// Break the whole word
|
currentWord += ch;
|
currentWordWidth += chWidth;
|
line = '';
|
accumWidth = currentWordWidth;
|
}
|
else {
|
// Append lastWord if have
|
if (currentWord) {
|
line += currentWord;
|
currentWord = '';
|
currentWordWidth = 0;
|
}
|
lines.push(line);
|
linesWidths.push(accumWidth);
|
|
line = ch;
|
accumWidth = chWidth;
|
}
|
}
|
|
continue;
|
}
|
|
accumWidth += chWidth;
|
|
if (inWord) {
|
currentWord += ch;
|
currentWordWidth += chWidth;
|
}
|
else {
|
// Append whole word
|
if (currentWord) {
|
line += currentWord;
|
// Reset
|
currentWord = '';
|
currentWordWidth = 0;
|
}
|
|
// Append character
|
line += ch;
|
}
|
}
|
|
if (!lines.length && !line) {
|
line = text;
|
currentWord = '';
|
currentWordWidth = 0;
|
}
|
|
// Append last line.
|
if (currentWord) {
|
line += currentWord;
|
}
|
if (line) {
|
lines.push(line);
|
linesWidths.push(accumWidth);
|
}
|
|
if (lines.length === 1) {
|
// No new line.
|
accumWidth += lastAccumWidth;
|
}
|
|
return {
|
// Accum width of last line
|
accumWidth,
|
lines: lines,
|
linesWidths
|
};
|
}
|