diff --git a/.eslintignore b/.eslintignore index 246d599d5..42d10fa1c 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,2 +1,4 @@ /dist -/node_modules \ No newline at end of file +/node_modules +/esm +/lib \ No newline at end of file diff --git a/src/canvas/Painter.ts b/src/canvas/Painter.ts index 8cfc42ad1..15028e2f7 100644 --- a/src/canvas/Painter.ts +++ b/src/canvas/Painter.ts @@ -437,7 +437,7 @@ export default class CanvasPainter implements PainterBase { for (i = start; i < layer.__endIndex; i++) { const el = list[i]; this._doPaintEl(el, layer, paintAll, scope); - el.__dirty = el.__dirtyText = false; + el.__dirty = false; if (useTimer) { // Date.now can be executed in 13,025,305 ops/second. diff --git a/src/contain/text.ts b/src/contain/text.ts index 71e6814d1..6a460975a 100644 --- a/src/contain/text.ts +++ b/src/contain/text.ts @@ -83,7 +83,7 @@ export function getBoundingRect( } export function adjustTextX(x: number, width: number, textAlign: CanvasTextAlign): number { - // FIXME Right to left language + // TODO Right to left language if (textAlign === 'right') { x -= width; } @@ -93,11 +93,11 @@ export function adjustTextX(x: number, width: number, textAlign: CanvasTextAlign return x; } -export function adjustTextY(y: number, height: number, textVerticalAlign: CanvasTextBaseline): number { - if (textVerticalAlign === 'middle') { +export function adjustTextY(y: number, height: number, textBaseline: CanvasTextBaseline): number { + if (textBaseline === 'middle') { y -= height / 2; } - else if (textVerticalAlign === 'bottom') { + else if (textBaseline === 'bottom') { y -= height; } return y; diff --git a/src/container/Group.ts b/src/container/Group.ts index 0fc1bbd5d..d563c19b8 100644 --- a/src/container/Group.ts +++ b/src/container/Group.ts @@ -75,9 +75,7 @@ export default class Group extends Element { */ add(child: Element): Group { if (child && child !== this && child.parent !== this) { - this._children.push(child); - this._doAdd(child); } diff --git a/src/container/RichText.ts b/src/container/RichText.ts index 463b78dbb..34817ac5f 100644 --- a/src/container/RichText.ts +++ b/src/container/RichText.ts @@ -1,15 +1,35 @@ +/** + * RichText is a container that manages complex text label. + * It will parse text string and create sub displayble elements respectively. + */ import { PatternObject } from '../graphic/Pattern' import { LinearGradientObject } from '../graphic/LinearGradient' import { RadialGradientObject } from '../graphic/RadialGradient' -import { TextAlign, TextVerticalAlign, ImageLike, Dictionary } from '../core/types' +import { TextAlign, TextVerticalAlign, ImageLike, Dictionary, AllPropTypes, PropType } from '../core/types' +import Element, { ElementOption } from '../Element' +import { parseRichText, parsePlainText, normalizeTextStyle } from './helper/text' +import ZText from '../graphic/Text'; +import { retrieve3, retrieve2, isString } from '../core/util' +import { DEFAULT_FONT, adjustTextX, adjustTextY, getWidth } from '../contain/text' +import { GradientObject } from '../graphic/Gradient' +import { Rect } from '../export' +import ZImage from '../graphic/Image' -export class RichTextStyleOption { +type RichTextContentBlock = ReturnType +type RichTextLine = PropType[0] +type RichTextToken = PropType[0] +// TODO Default value +interface RichTextStyleOptionPart { // TODO Text is assigned inside zrender text?: string // TODO Text not support PatternObject | LinearGradientObject | RadialGradientObject yet. textFill?: string | PatternObject | LinearGradientObject | RadialGradientObject textStroke?: string | PatternObject | LinearGradientObject | RadialGradientObject + + opacity: number + fillOpacity: number + strokeOpacity: number /** * textStroke may be set as some color as a default * value in upper applicaion, where the default value @@ -29,7 +49,7 @@ export class RichTextStyleOption { * The same as font. Use font please. * @deprecated */ - textFont?: string + // textFont?: string /** * It helps merging respectively, rather than parsing an entire font string. @@ -86,17 +106,425 @@ export class RichTextStyleOption { textBoxShadowBlur?: number textBoxShadowOffsetX?: number textBoxShadowOffsetY?: number - +} +export interface RichTextStyleOption extends RichTextStyleOptionPart { + x: number, + y: number, + /** + * If wrap text + */ + wrap: false, /** * Text styles for rich text. */ - // rich?: Dictionary - - // truncate?: { - // outerWidth?: number - // outerHeight?: number - // ellipsis?: string - // placeholder?: string - // minChar?: number - // } -} \ No newline at end of file + rich?: Dictionary + + truncate?: { + outerWidth?: number + outerHeight?: number + ellipsis?: string + placeholder?: string + minChar?: number + } +} + +interface RichTextOption extends ElementOption { + style?: RichTextStyleOption +} + +interface RichText { + attr(key: RichTextOption): RichText + attr(key: keyof RichTextOption, value: AllPropTypes): RichText +} +class RichText extends Element { + + private _children: Element[] = [] + + private _styleChanged = true + + // TODO RichText is Group? + readonly isGroup = true + + style?: RichTextStyleOption + + constructor(opts?: RichTextOption) { + super(); + this.attr(opts); + } + + update() { + // Update children + if (this._styleChanged) { + // TODO Reuse + this._children = []; + + normalizeTextStyle(this.style); + this.style.rich + ? this._updateRichTexts() + : this._updatePlainTexts(); + } + super.update(); + } + + + setStyle(obj: RichTextStyleOption): void + setStyle(obj: keyof RichTextStyleOption, value: any): void + setStyle(obj: keyof RichTextStyleOption | RichTextStyleOption, value?: AllPropTypes) { + if (typeof obj === 'string') { + (this.style as Dictionary)[obj] = value; + } + else { + for (let key in obj) { + if (obj.hasOwnProperty(key)) { + (this.style as Dictionary)[key] = (obj as Dictionary)[key]; + } + } + } + this.dirty(); + return this; + } + + dirtyStyle() { + this._styleChanged = true; + this.dirty(); + } + + children() { + return this._children; + } + + private _addChild(child: ZText | Rect | ZImage): void { + this._children.push(child); + child.parent = this; + // PENDING addToStorage? + } + + private _updateRichTexts() { + const style = this.style; + + // TODO Only parse when text changed? + const contentBlock = parseRichText(style.text || '', style); + + const contentWidth = contentBlock.width; + const outerWidth = contentBlock.outerWidth; + const outerHeight = contentBlock.outerHeight; + const textPadding = style.textPadding as number[]; + + const baseX = style.x || 0; + const baseY = style.y || 0; + const textAlign = style.textAlign; + const textVerticalAlign = style.textVerticalAlign; + + const boxX = adjustTextX(baseX, outerWidth, textAlign); + const boxY = adjustTextY(baseY, outerHeight, textVerticalAlign); + let xLeft = boxX; + let lineTop = boxY; + if (textPadding) { + xLeft += textPadding[3]; + lineTop += textPadding[0]; + } + const xRight = xLeft + contentWidth; + + for (let i = 0; i < contentBlock.lines.length; i++) { + const line = contentBlock.lines[i]; + const tokens = line.tokens; + const tokenCount = tokens.length; + const lineHeight = line.lineHeight; + + let usedWidth = line.width; + let leftIndex = 0; + let lineXLeft = xLeft; + let lineXRight = xRight; + let rightIndex = tokenCount - 1; + let token; + + while ( + leftIndex < tokenCount + && (token = tokens[leftIndex], !token.textAlign || token.textAlign === 'left') + ) { + this._placeToken(token, style, lineHeight, lineTop, lineXLeft, 'left'); + usedWidth -= token.width; + lineXLeft += token.width; + leftIndex++; + } + + while ( + rightIndex >= 0 + && (token = tokens[rightIndex], token.textAlign === 'right') + ) { + this._placeToken(token, style, lineHeight, lineTop, lineXRight, 'right'); + usedWidth -= token.width; + lineXRight -= token.width; + rightIndex--; + } + + // The other tokens are placed as textAlign 'center' if there is enough space. + lineXLeft += (contentWidth - (lineXLeft - xLeft) - (xRight - lineXRight) - usedWidth) / 2; + while (leftIndex <= rightIndex) { + token = tokens[leftIndex]; + // Consider width specified by user, use 'center' rather than 'left'. + this._placeToken(token, style, lineHeight, lineTop, lineXLeft + token.width / 2, 'center'); + lineXLeft += token.width; + leftIndex++; + } + + lineTop += lineHeight; + } + } + + private _updatePlainTexts() { + const style = this.style; + const text= style.text || ''; + const textFont = style.font || DEFAULT_FONT; + const textPadding = style.textPadding as number[]; + const textLineHeight = style.textLineHeight; + + const contentBlock = parsePlainText( + text, + textFont, + // textPadding has been normalized + textPadding as number[], + textLineHeight, + style.truncate + ); + const needDrawBg = needDrawBackground(style); + + let outerHeight = contentBlock.outerHeight; + + const textLines = contentBlock.lines; + const lineHeight = contentBlock.lineHeight; + + const baseX = style.x || 0; + const baseY = style.y || 0; + const textAlign = style.textAlign || 'left'; + const textVerticalAlign = style.textVerticalAlign; + + const boxY = adjustTextY(baseY, outerHeight, textVerticalAlign); + let textX = baseX; + let textY = boxY; + + if (needDrawBg || textPadding) { + // Consider performance, do not call getTextWidth util necessary. + const textWidth = getWidth(text, textFont); + let outerWidth = textWidth; + textPadding && (outerWidth += textPadding[1] + textPadding[3]); + const boxX = adjustTextX(baseX, outerWidth, textAlign); + + needDrawBg && this._renderBackground(style, boxX, boxY, outerWidth, outerHeight); + + if (textPadding) { + textX = getTextXForPadding(baseX, textAlign, textPadding); + textY += textPadding[0]; + } + } + + // `textBaseline` is set as 'middle'. + textY += lineHeight / 2; + + const textStrokeWidth = style.textStrokeWidth; + const textStroke = getStroke(style.textStroke, textStrokeWidth); + const textFill = getFill(style.textFill); + + for (let i = 0; i < textLines.length; i++) { + const el = new ZText(); + const subElStyle = el.style; + subElStyle.text = textLines[i]; + subElStyle.x = textX; + subElStyle.y = textY; + // Always set textAlign and textBase line, because it is difficute to calculate + // textAlign from prevEl, and we dont sure whether textAlign will be reset if + // font set happened. + subElStyle.textAlign = textAlign; + // Force baseline to be "middle". Otherwise, if using "top", the + // text will offset downward a little bit in font "Microsoft YaHei". + subElStyle.textBaseline = 'middle'; + subElStyle.opacity = style.opacity; + // Fill after stroke so the outline will not cover the main part. + subElStyle.strokeFirst = true; + + subElStyle.shadowBlur = style.textShadowBlur || style.textShadowBlur || 0; + subElStyle.shadowColor = style.textShadowColor || style.textShadowColor || 'transparent'; + subElStyle.shadowOffsetX = style.textShadowOffsetX || style.textShadowOffsetX || 0; + subElStyle.shadowOffsetY = style.textShadowOffsetY || style.textShadowOffsetY || 0; + + subElStyle.lineWidth = textStrokeWidth; + subElStyle.stroke = textStroke as string; + subElStyle.fill = textFill as string; + + subElStyle.font = textFont; + + textY += lineHeight; + + this._addChild(el); + } + } + + private _placeToken( + token: RichTextToken, + style: RichTextStyleOption, + lineHeight: number, + lineTop: number, + x: number, + textAlign: string + ) { + const tokenStyle = style.rich[token.styleName] || {} as RichTextStyleOptionPart; + tokenStyle.text = token.text; + + // 'ctx.textBaseline' is always set as 'middle', for sake of + // the bias of "Microsoft YaHei". + const textVerticalAlign = token.textVerticalAlign; + let y = lineTop + lineHeight / 2; + if (textVerticalAlign === 'top') { + y = lineTop + token.height / 2; + } + else if (textVerticalAlign === 'bottom') { + y = lineTop + lineHeight - token.height / 2; + } + + !token.isLineHolder && needDrawBackground(tokenStyle) && this._renderBackground( + tokenStyle, + textAlign === 'right' + ? x - token.width + : textAlign === 'center' + ? x - token.width / 2 + : x, + y - token.height / 2, + token.width, + token.height + ); + + const textPadding = token.textPadding; + if (textPadding) { + x = getTextXForPadding(x, textAlign, textPadding); + y -= token.height / 2 - textPadding[2] - token.textHeight / 2; + } + + const el = new ZText(); + const subElStyle = el.style; + subElStyle.text = token.text; + subElStyle.x = x; + subElStyle.y = y; + subElStyle.shadowBlur = tokenStyle.textShadowBlur || style.textShadowBlur || 0; + subElStyle.shadowColor = tokenStyle.textShadowColor || style.textShadowColor || 'transparent'; + subElStyle.shadowOffsetX = tokenStyle.textShadowOffsetX || style.textShadowOffsetX || 0; + subElStyle.shadowOffsetY = tokenStyle.textShadowOffsetY || style.textShadowOffsetY || 0; + + subElStyle.textAlign = textAlign as CanvasTextAlign; + // Force baseline to be "middle". Otherwise, if using "top", the + // text will offset downward a little bit in font "Microsoft YaHei". + subElStyle.textBaseline = 'middle'; + subElStyle.font = token.font || DEFAULT_FONT; + + + subElStyle.lineWidth = retrieve2(tokenStyle.textStrokeWidth, style.textStrokeWidth); + subElStyle.stroke = getStroke(tokenStyle.textStroke || style.textStroke, subElStyle.lineWidth) || null; + subElStyle.fill = getFill(tokenStyle.textFill || style.textFill) || null; + + this._addChild(el); + } + + private _renderBackground( + style: RichTextStyleOptionPart, + x: number, + y: number, + width: number, + height: number + ) { + const textBackgroundColor = style.textBackgroundColor; + const textBorderWidth = style.textBorderWidth; + const textBorderColor = style.textBorderColor; + const isPlainBg = isString(textBackgroundColor); + const textBorderRadius = style.textBorderRadius; + + let rectEl: Rect; + let imgEl: ZImage; + if (isPlainBg || (textBorderWidth && textBorderColor)) { + // Background is color + rectEl = new Rect(); + const rectShape = rectEl.shape; + rectShape.x = x; + rectShape.y = y; + rectShape.width = width; + rectShape.height = height; + rectShape.r = textBorderRadius; + + this._addChild(rectEl); + } + + if (isPlainBg) { + const rectStyle = rectEl.style; + rectStyle.fill = textBackgroundColor as string; + rectStyle.opacity = retrieve2(style.opacity, 1); + rectStyle.fillOpacity = retrieve2(style.fillOpacity, 1); + } + else if (textBackgroundColor && (textBackgroundColor as {image: ImageLike}).image) { + imgEl = new ZImage(); + const imgStyle = imgEl.style; + imgStyle.image = (textBackgroundColor as {image: ImageLike}).image; + imgStyle.x = x; + imgStyle.y = y; + imgStyle.width = width; + imgStyle.height = height; + this._addChild(imgEl); + } + + if (textBorderWidth && textBorderColor) { + const rectStyle = rectEl.style; + rectStyle.lineWidth = textBorderWidth; + rectStyle.stroke = textBorderColor; + rectStyle.strokeOpacity = retrieve2(style.strokeOpacity, 1); + } + + const shadowStyle = (rectEl || imgEl).style; + shadowStyle.shadowBlur = style.textBoxShadowBlur || 0; + shadowStyle.shadowColor = style.textBoxShadowColor || 'transparent'; + shadowStyle.shadowOffsetX = style.textBoxShadowOffsetX || 0; + shadowStyle.shadowOffsetY = style.textBoxShadowOffsetY || 0; + + } +} + + +/** + * @param stroke If specified, do not check style.textStroke. + * @param lineWidth If specified, do not check style.textStroke. + */ +function getStroke( + stroke?: PropType, + lineWidth?: number +) { + return (stroke == null || lineWidth <= 0 || stroke === 'transparent' || stroke === 'none') + ? null + : ((stroke as PatternObject).image || (stroke as GradientObject).colorStops) + ? '#000' + : stroke; +} + +function getFill( + fill?: PropType +) { + return (fill == null || fill === 'none') + ? null + // TODO pattern and gradient? + : ((fill as PatternObject).image || (fill as GradientObject).colorStops) + ? '#000' + : fill; +} + +function getTextXForPadding(x: number, textAlign: string, textPadding: number[]): number { + return textAlign === 'right' + ? (x - textPadding[1]) + : textAlign === 'center' + ? (x + textPadding[3] / 2 - textPadding[1] / 2) + : (x + textPadding[3]); +} + +function needDrawBackground(style: RichTextStyleOptionPart): boolean { + return !!( + style.textBackgroundColor + || (style.textBorderWidth && style.textBorderColor) + ); +} + + +export default RichText; \ No newline at end of file diff --git a/src/container/helper/text.ts b/src/container/helper/text.ts index 32eb093e1..a105ac8e2 100644 --- a/src/container/helper/text.ts +++ b/src/container/helper/text.ts @@ -1 +1,635 @@ +import * as imageHelper from '../../graphic/helper/image'; +import { + getContext, + extend, + retrieve2, + retrieve3, + trim, + each, + normalizeCssArray +} from '../../core/util'; +import { PropType, TextAlign, TextVerticalAlign, ImageLike } from '../../core/types'; +import { RichTextStyleOption } from '../RichText'; +import { getLineHeight, getWidth } from '../../contain/text'; +import ZText from '../../graphic/Text'; + +const STYLE_REG = /\{([a-zA-Z0-9_]+)\|([^}]*)\}/g; +const VALID_TEXT_ALIGN = {left: true, right: 1, center: 1}; +const VALID_TEXT_VERTICAL_ALIGN = {top: 1, bottom: 1, middle: 1}; + +type RichTextStyleOptionPart = PropType[string]; + +export function normalizeTextStyle(style: RichTextStyleOption): RichTextStyleOption { + normalizeStyle(style); + each(style.rich, normalizeStyle); + return style; +} + +function normalizeStyle(style: RichTextStyleOptionPart) { + if (style) { + style.font = ZText.makeFont(style); + let textAlign = style.textAlign; + // 'middle' is invalid, convert it to 'center' + (textAlign as string) === 'middle' && (textAlign = 'center'); + style.textAlign = ( + textAlign == null || VALID_TEXT_ALIGN[textAlign] + ) ? textAlign : 'left'; + + // Compatible with textBaseline. + let textVerticalAlign = style.textVerticalAlign; + (textVerticalAlign as string) === 'center' && (textVerticalAlign = 'middle'); + style.textVerticalAlign = ( + textVerticalAlign == null || VALID_TEXT_VERTICAL_ALIGN[textVerticalAlign] + ) ? textVerticalAlign : 'top'; + + // TODO Should not change the orignal value. + const textPadding = style.textPadding; + if (textPadding) { + style.textPadding = normalizeCssArray(style.textPadding); + } + } +} + +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 { + 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; +} + +/** + * Notice: for performance, do not calculate outerWidth util needed. + * `canCacheByTextString` means the result `lines` is only determined by the input `text`. + * Thus we can simply comparing the `input` text to determin whether the result changed, + * without travel the result `lines`. + */ +export interface PlainTextContentBlock { + lineHeight: number + height: number + outerHeight: number + canCacheByTextString: boolean + + lines: string[] +} + +export function parsePlainText( + text: string, + font: string, + padding: number[], + textLineHeight: number, + truncate: PropType +): PlainTextContentBlock { + text != null && (text += ''); + + const lineHeight = retrieve2(textLineHeight, getLineHeight(font)); + let lines = text ? text.split('\n') : []; + let height = lines.length * lineHeight; + let outerHeight = height; + let canCacheByTextString = true; + + if (padding) { + outerHeight += padding[0] + padding[2]; + } + + if (text && truncate) { + canCacheByTextString = false; + const truncOuterHeight = truncate.outerHeight; + const truncOuterWidth = truncate.outerWidth; + if (truncOuterHeight != null && outerHeight > truncOuterHeight) { + text = ''; + lines = []; + } + else if (truncOuterWidth != null) { + const options = prepareTruncateOptions( + truncOuterWidth - (padding ? padding[1] + padding[3] : 0), + font, + truncate.ellipsis, + { + minChar: truncate.minChar, + placeholder: truncate.placeholder + } + ); + + // FIXME + // It is not appropriate that every line has '...' when truncate multiple lines. + for (let i = 0, len = lines.length; i < len; i++) { + lines[i] = truncateSingleLine(lines[i], options); + } + } + } + + return { + lines: lines, + height: height, + outerHeight: outerHeight, + lineHeight: lineHeight, + canCacheByTextString: canCacheByTextString + }; +} + +class RichTextToken { + styleName: string + text: string + width: number + height: number + textWidth: number | string + textHeight: number + lineHeight: number + font: string + textAlign: TextAlign + textVerticalAlign: 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 + // outerWidth/outerHeight with padding + outerWidth: number = 0 + outerHeight: number = 0 + lines: RichTextLine[] = [] +} +/** + * 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: RichTextStyleOption) { + const contentBlock = new RichTextContentBlock(); + + text != null && (text += ''); + if (!text) { + return contentBlock; + } + + 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)); + } + pushTokens(contentBlock, result[2], result[1]); + lastIndex = STYLE_REG.lastIndex; + } + + if (lastIndex < text.length) { + pushTokens(contentBlock, text.substring(lastIndex, text.length)); + } + + const lines = contentBlock.lines; + let contentHeight = 0; + let contentWidth = 0; + // For `textWidth: 100%` + let pendingList = []; + + const stlPadding = style.textPadding as number[]; + + const truncate = style.truncate; + let truncateWidth = truncate && truncate.outerWidth; + let truncateHeight = truncate && truncate.outerHeight; + if (stlPadding) { + truncateWidth != null && (truncateWidth -= stlPadding[1] + stlPadding[3]); + truncateHeight != null && (truncateHeight -= stlPadding[0] + stlPadding[2]); + } + + // Calculate layout info of tokens. + for (let i = 0; i < lines.length; i++) { + const line = 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] || {} as RichTextStyleOptionPart; + // textPadding should not inherit from style. + const textPadding = token.textPadding = tokenStyle.textPadding as number[]; + + const font = token.font = tokenStyle.font || style.font; + + // textHeight can be used when textVerticalAlign is specified in token. + let tokenHeight = token.textHeight = retrieve2( + // textHeight should not be inherited, consider it can be specified + // as box height of the block. + tokenStyle.textHeight, getLineHeight(font) + ) ; + textPadding && (tokenHeight += textPadding[0] + textPadding[2]); + token.height = tokenHeight; + token.lineHeight = retrieve3( + tokenStyle.textLineHeight, style.textLineHeight, tokenHeight + ); + + token.textAlign = tokenStyle && tokenStyle.textAlign || style.textAlign; + token.textVerticalAlign = tokenStyle && tokenStyle.textVerticalAlign || 'middle'; + + if (truncateHeight != null && contentHeight + token.lineHeight > truncateHeight) { + return new RichTextContentBlock(); + } + + token.textWidth = getWidth(token.text, font); + let tokenWidth = tokenStyle.textWidth; + let tokenWidthNotSpecified = tokenWidth == null || tokenWidth === 'auto'; + + // Percent width, can be `100%`, can be used in drawing separate + // line when box width is needed to be auto. + if (typeof tokenWidth === 'string' && tokenWidth.charAt(tokenWidth.length - 1) === '%') { + token.percentWidth = tokenWidth; + pendingList.push(token); + tokenWidth = 0; + // Do not truncate in this case, because there is no user case + // and it is too complicated. + } + else { + if (tokenWidthNotSpecified) { + tokenWidth = token.textWidth; + + // FIXME: If image is not loaded and textWidth is not specified, calling + // `getBoundingRect()` will not get correct result. + const textBackgroundColor = tokenStyle.textBackgroundColor; + let bgImg = textBackgroundColor && (textBackgroundColor as { image: ImageLike }).image; + + // Use cases: + // (1) If image is not loaded, it will be loaded at render phase and call + // `dirty()` and `textBackgroundColor.image` will be replaced with the loaded + // image, and then the right size will be calculated here at the next tick. + // See `graphic/helper/text.js`. + // (2) If image loaded, and `textBackgroundColor.image` is image src string, + // use `imageHelper.findExistImage` to find cached image. + // `imageHelper.findExistImage` will always be called here before + // `imageHelper.createOrUpdateImage` in `graphic/helper/text.js#renderRichText` + // which ensures that image will not be rendered before correct size calcualted. + if (bgImg) { + bgImg = imageHelper.findExistImage(bgImg); + if (imageHelper.isImageReady(bgImg)) { + tokenWidth = Math.max(tokenWidth, bgImg.width * tokenHeight / bgImg.height); + } + } + } + + const paddingW = textPadding ? textPadding[1] + textPadding[3] : 0; + (tokenWidth as number) += paddingW; + + const remianTruncWidth = truncateWidth != null ? truncateWidth - lineWidth : null; + + if (remianTruncWidth != null && remianTruncWidth < tokenWidth) { + if (!tokenWidthNotSpecified || remianTruncWidth < paddingW) { + token.text = ''; + token.textWidth = tokenWidth = 0; + } + else { + token.text = truncateText( + token.text, remianTruncWidth - paddingW, font, truncate.ellipsis, + {minChar: truncate.minChar} + ); + token.textWidth = getWidth(token.text, font); + tokenWidth = token.textWidth + paddingW; + } + } + } + + lineWidth += (token.width = tokenWidth as number); + tokenStyle && (lineHeight = Math.max(lineHeight, token.lineHeight)); + } + + line.width = lineWidth; + line.lineHeight = lineHeight; + contentHeight += lineHeight; + contentWidth = Math.max(contentWidth, lineWidth); + } + + contentBlock.outerWidth = contentBlock.width = retrieve2(style.textWidth as number, contentWidth); + contentBlock.outerHeight = contentBlock.height = retrieve2(style.textHeight as number, contentHeight); + + 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 * contentWidth; + } + + return contentBlock; +} + +function pushTokens(block: RichTextContentBlock, str: string, styleName?: string) { + const isEmptyStr = str === ''; + const strs = str.split('\n'); + const lines = block.lines; + + for (let i = 0; i < strs.length; i++) { + const text = strs[i]; + const token = new RichTextToken() + token.styleName = styleName; + token.text = text; + token.isLineHolder = !text && !isEmptyStr + + // The first token should be appended to the last line. + if (!i) { + 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 parsePercent(value: number | string, maxValue: number): number{ +// if (typeof value === 'string') { +// if (value.lastIndexOf('%') >= 0) { +// return parseFloat(value) / 100 * maxValue; +// } +// return parseFloat(value); +// } +// return value; +// } + +// class TextPositionCalculationResult { +// x: number +// y: number +// textAlign: TextAlign +// textVerticalAlign: TextVerticalAlign +// } +/** + * Follow same interface to `Displayable.prototype.calculateTextPosition`. * @public + * @param out Prepared out object. If not input, auto created in the method. + * @param style where `textPosition` and `textDistance` are visited. + * @param rect {x, y, width, height} Rect of the host elment, according to which the text positioned. + * @return The input `out`. Set: {x, y, textAlign, textVerticalAlign} + */ +// export function calculateTextPosition( +// out: TextPositionCalculationResult, +// style: RichTextStyleOption, +// rect: RectLike +// ): TextPositionCalculationResult { +// const textPosition = style.textPosition; +// let distance = style.textDistance; + +// const height = rect.height; +// const width = rect.width; +// const halfHeight = height / 2; + +// let x = rect.x; +// let y = rect.y; +// distance = distance || 0; + +// let textAlign: TextAlign= 'left'; +// let textVerticalAlign: TextVerticalAlign = 'top'; + +// switch (textPosition) { +// case 'left': +// x -= distance; +// y += halfHeight; +// textAlign = 'right'; +// textVerticalAlign = 'middle'; +// break; +// case 'right': +// x += distance + width; +// y += halfHeight; +// textVerticalAlign = 'middle'; +// break; +// case 'top': +// x += width / 2; +// y -= distance; +// textAlign = 'center'; +// textVerticalAlign = 'bottom'; +// break; +// case 'bottom': +// x += width / 2; +// y += height + distance; +// textAlign = 'center'; +// break; +// case 'inside': +// x += width / 2; +// y += halfHeight; +// textAlign = 'center'; +// textVerticalAlign = 'middle'; +// break; +// case 'insideLeft': +// x += distance; +// y += halfHeight; +// textVerticalAlign = 'middle'; +// break; +// case 'insideRight': +// x += width - distance; +// y += halfHeight; +// textAlign = 'right'; +// textVerticalAlign = 'middle'; +// break; +// case 'insideTop': +// x += width / 2; +// y += distance; +// textAlign = 'center'; +// break; +// case 'insideBottom': +// x += width / 2; +// y += height - distance; +// textAlign = 'center'; +// textVerticalAlign = 'bottom'; +// break; +// case 'insideTopLeft': +// x += distance; +// y += distance; +// break; +// case 'insideTopRight': +// x += width - distance; +// y += distance; +// textAlign = 'right'; +// break; +// case 'insideBottomLeft': +// x += distance; +// y += height - distance; +// textVerticalAlign = 'bottom'; +// break; +// case 'insideBottomRight': +// x += width - distance; +// y += height - distance; +// textAlign = 'right'; +// textVerticalAlign = 'bottom'; +// break; +// } + +// out = out || {} as TextPositionCalculationResult; +// out.x = x; +// out.y = y; +// out.textAlign = textAlign; +// out.textVerticalAlign = textVerticalAlign; + +// return out; +// } \ No newline at end of file diff --git a/src/core/types.ts b/src/core/types.ts index c63ec777e..85bfd80ae 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -13,12 +13,14 @@ export type ArrayLike = { export type ImageLike = HTMLImageElement | HTMLCanvasElement | HTMLVideoElement +// subset of CanvasTextBaseline export type TextVerticalAlign = 'top' | 'middle' | 'bottom' - | 'center' // DEPRECATED + // | 'center' // DEPRECATED // TODO: Have not support 'start', 'end' yet. +// subset of CanvasTextAlign export type TextAlign = 'left' | 'center' | 'right' - | 'middle' // DEPRECATED + // | 'middle' // DEPRECATED export type WXCanvasRenderingContext = CanvasRenderingContext2D & { draw: () => void diff --git a/src/export.ts b/src/export.ts index 9217f3afe..eb6dc9c94 100644 --- a/src/export.ts +++ b/src/export.ts @@ -16,6 +16,7 @@ export {default as Image} from './graphic/Image'; export {default as CompoundPath} from './graphic/CompoundPath'; export {default as Text} from './graphic/Text'; export {default as IncrementalDisplayable} from './graphic/IncrementalDisplayable'; +export {default as RichText} from './container/RichText'; export {default as Arc} from './graphic/shape/Arc'; export {default as BezierCurve} from './graphic/shape/BezierCurve'; diff --git a/src/graphic/Displayable.ts b/src/graphic/Displayable.ts index d2774e6d2..789f587c4 100644 --- a/src/graphic/Displayable.ts +++ b/src/graphic/Displayable.ts @@ -7,9 +7,8 @@ import Element, {ElementOption} from '../Element'; import BoundingRect, { RectLike } from '../core/BoundingRect'; import { Dictionary, PropType, AllPropTypes } from '../core/types'; import Path from './Path'; -import { calculateTextPosition, RichTextContentBlock, PlainTextContentBlock } from '../container/helper/text'; -type CalculateTextPositionResult = ReturnType +// type CalculateTextPositionResult = ReturnType export interface CommonStyleOption { shadowBlur?: number @@ -243,7 +242,7 @@ export default class Displayable extends Element { * textVerticalAlign: string. optional. use style.textVerticalAlign by default. * } */ - calculateTextPosition: (out: CalculateTextPositionResult, style: Dictionary, rect: RectLike) => CalculateTextPositionResult + // calculateTextPosition: (out: CalculateTextPositionResult, style: Dictionary, rect: RectLike) => CalculateTextPositionResult protected static initDefaultProps = (function () { diff --git a/src/graphic/Text.ts b/src/graphic/Text.ts index aa5a21462..70a4f31ad 100644 --- a/src/graphic/Text.ts +++ b/src/graphic/Text.ts @@ -67,19 +67,6 @@ interface TextOption extends DisplayableOption { style?: TextOption } -function makeFont(style: TextStyleOption): string { - // FIXME in node-canvas fontWeight is before fontStyle - // Use `fontSize` `fontFamily` to check whether font properties are defined. - const font = (style.fontSize || style.fontFamily) && [ - style.fontStyle, - style.fontWeight, - (style.fontSize || 12) + 'px', - // If font properties are defined, `fontFamily` should not be ignored. - style.fontFamily || 'sans-serif' - ].join(' '); - return font && trim(font) || style.textFont || style.font; -} - interface ZText { constructor(opts?: TextOption): void @@ -96,7 +83,7 @@ class ZText extends Displayable { style: TextStyleOption private _normalizeFont() { - this.style.font = makeFont(this.style); + this.style.font = ZText.makeFont(this.style); } hasStroke() { @@ -154,5 +141,20 @@ class ZText extends Displayable { return this._rect; } + + static makeFont( + style: Pick + ): string { + // FIXME in node-canvas fontWeight is before fontStyle + // Use `fontSize` `fontFamily` to check whether font properties are defined. + const font = (style.fontSize || style.fontFamily) && [ + style.fontStyle, + style.fontWeight, + (style.fontSize || 12) + 'px', + // If font properties are defined, `fontFamily` should not be ignored. + style.fontFamily || 'sans-serif' + ].join(' '); + return font && trim(font) || style.textFont || style.font; + } } export default ZText; \ No newline at end of file diff --git a/src/svg/Painter.ts b/src/svg/Painter.ts index ccec98e0d..5a211c63e 100644 --- a/src/svg/Painter.ts +++ b/src/svg/Painter.ts @@ -21,7 +21,6 @@ import Displayable from '../graphic/Displayable'; import Storage from '../Storage'; import { GradientObject } from '../graphic/Gradient'; import { PainterBase } from '../PainterBase'; -import CanvasPainter from '../canvas/Painter'; function parseInt10(val: string) { return parseInt(val, 10); diff --git a/src/svg/graphic.ts b/src/svg/graphic.ts index c0fb856cf..2790ef030 100644 --- a/src/svg/graphic.ts +++ b/src/svg/graphic.ts @@ -9,7 +9,7 @@ import Displayable from '../graphic/Displayable'; import { Path } from '../export'; import { PathStyleOption } from '../graphic/Path'; import ZImage, { ImageStyleOption } from '../graphic/Image'; -import { DEFAULT_FONT, adjustTextY, getLineHeight } from '../contain/text'; +import { DEFAULT_FONT, getLineHeight } from '../contain/text'; import ZText, { TextStyleOption } from '../graphic/Text'; type SVGProxy = { @@ -334,6 +334,17 @@ const TEXT_ALIGN_TO_ANCHOR = { middle: 'middle' }; +function adjustTextY(y: number, lineHeight: number, textBaseline: CanvasTextBaseline): number { + // TODO Other values. + if (textBaseline === 'top') { + y += lineHeight / 2; + } + else if (textBaseline === 'bottom') { + y -= lineHeight / 2; + } + return y; +} + const svgText: SVGProxy = { brush(el: ZText) { const style = el.style; diff --git a/test/text.html b/test/text.html index 0d478d731..cbffece2c 100644 --- a/test/text.html +++ b/test/text.html @@ -19,20 +19,13 @@ var showBoundingRect; - // requireES([ - // 'zrender/esm/zrender', - // 'zrender/esm/graphic/Text', - // 'zrender/esm/graphic/shape/Line', - // 'zrender/esm/graphic/shape/Rect', - // 'zrender/esm/vml/vml' - // ], function(zrender, Text, Line, Rect){ - - var Text = zrender.Text; + var RichText = zrender.RichText; var Line = zrender.Line; var Rect = zrender.Rect; var zr = zrender.init(document.getElementById('main'), { - renderer: window.__ZRENDER__DEFAULT__RENDERER__ + // renderer: window.__ZRENDER__DEFAULT__RENDERER__ + renderer: 'svg' }); showBoundingRect = function () { @@ -94,7 +87,7 @@ })); } - zr.add(new Text({ + zr.add(new RichText({ style: { x: 0, y: 0, @@ -107,7 +100,7 @@ draggable: true })); - zr.add(new Text({ + zr.add(new RichText({ position : [100, 100], style: { x: 0, @@ -123,7 +116,7 @@ draggable: true })); - zr.add(new Text({ + zr.add(new RichText({ position : [200, 100], rotation: 2, scale: [.5, .5], @@ -139,7 +132,7 @@ draggable: true })); - zr.add(new Text({ + zr.add(new RichText({ position : [100, 200], rotation: -1, origin: [0, 50], @@ -167,7 +160,7 @@ fill: '#333' } })); - zr.add(new Text({ + zr.add(new RichText({ position : [300, 100], style: { x: 0, @@ -178,7 +171,7 @@ } })); - zr.add(new Text({ + zr.add(new RichText({ position : [400, 50], style: { x: 0, @@ -190,7 +183,7 @@ } })); - zr.add(new Text({ + zr.add(new RichText({ position : [500, 50], style: { x: 0, @@ -202,7 +195,7 @@ } })); - zr.add(new Text({ + zr.add(new RichText({ position : [600, 0], style: { x: 0, @@ -217,7 +210,7 @@ } })); - zr.add(new Text({ + zr.add(new RichText({ position : [700, 0], style: { x: 0, @@ -234,7 +227,7 @@ } })); - zr.add(new Text({ + zr.add(new RichText({ position : [600, 100], rotation: -Math.PI / 16, style: { @@ -265,7 +258,7 @@ })); - zr.add(new Text({ + zr.add(new RichText({ position : [100, 300], style: { // Empty text but has background @@ -278,7 +271,7 @@ })); - zr.add(new Text({ + zr.add(new RichText({ style: { x: 200, y: 300, @@ -405,7 +398,7 @@ draggable: true })); - zr.add(new Text({ + zr.add(new RichText({ rotation: -0.1, position: [500, 900], style: { @@ -464,7 +457,7 @@ })); - zr.add(new Text({ + zr.add(new RichText({ rotation: -0.1, position: [500, 1300], style: { @@ -533,7 +526,7 @@ ['#c23531','#2f4554', '#61a0a8', '#d48265', '#91c7ae','#749f83', '#ca8622', '#bda29a','#6e7074', '#546570', '#c4ccd3'] ); - zr.add(new Text({ + zr.add(new RichText({ rotation: -0.1, position: [800, 1100], style: {