textBlock.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472
  1. import { Observable } from "babylonjs/Misc/observable";
  2. import { Measure } from "../measure";
  3. import { ValueAndUnit } from "../valueAndUnit";
  4. import { Control } from "./control";
  5. import { _TypeStore } from 'babylonjs/Misc/typeStore';
  6. import { Nullable } from 'babylonjs/types';
  7. /**
  8. * Enum that determines the text-wrapping mode to use.
  9. */
  10. export enum TextWrapping {
  11. /**
  12. * Clip the text when it's larger than Control.width; this is the default mode.
  13. */
  14. Clip = 0,
  15. /**
  16. * Wrap the text word-wise, i.e. try to add line-breaks at word boundary to fit within Control.width.
  17. */
  18. WordWrap = 1,
  19. /**
  20. * Ellipsize the text, i.e. shrink with trailing … when text is larger than Control.width.
  21. */
  22. Ellipsis,
  23. }
  24. /**
  25. * Class used to create text block control
  26. */
  27. export class TextBlock extends Control {
  28. private _text = "";
  29. private _textWrapping = TextWrapping.Clip;
  30. private _textHorizontalAlignment = Control.HORIZONTAL_ALIGNMENT_CENTER;
  31. private _textVerticalAlignment = Control.VERTICAL_ALIGNMENT_CENTER;
  32. private _lines: any[];
  33. private _resizeToFit: boolean = false;
  34. private _lineSpacing: ValueAndUnit = new ValueAndUnit(0);
  35. private _outlineWidth: number = 0;
  36. private _outlineColor: string = "white";
  37. /**
  38. * An event triggered after the text is changed
  39. */
  40. public onTextChangedObservable = new Observable<TextBlock>();
  41. /**
  42. * An event triggered after the text was broken up into lines
  43. */
  44. public onLinesReadyObservable = new Observable<TextBlock>();
  45. /**
  46. * Function used to split a string into words. By default, a string is split at each space character found
  47. */
  48. public wordSplittingFunction: Nullable<(line: string) => string[]>;
  49. /**
  50. * Return the line list (you may need to use the onLinesReadyObservable to make sure the list is ready)
  51. */
  52. public get lines(): any[] {
  53. return this._lines;
  54. }
  55. /**
  56. * Gets or sets an boolean indicating that the TextBlock will be resized to fit container
  57. */
  58. public get resizeToFit(): boolean {
  59. return this._resizeToFit;
  60. }
  61. /**
  62. * Gets or sets an boolean indicating that the TextBlock will be resized to fit container
  63. */
  64. public set resizeToFit(value: boolean) {
  65. if (this._resizeToFit === value) {
  66. return;
  67. }
  68. this._resizeToFit = value;
  69. if (this._resizeToFit) {
  70. this._width.ignoreAdaptiveScaling = true;
  71. this._height.ignoreAdaptiveScaling = true;
  72. }
  73. this._markAsDirty();
  74. }
  75. /**
  76. * Gets or sets a boolean indicating if text must be wrapped
  77. */
  78. public get textWrapping(): TextWrapping | boolean {
  79. return this._textWrapping;
  80. }
  81. /**
  82. * Gets or sets a boolean indicating if text must be wrapped
  83. */
  84. public set textWrapping(value: TextWrapping | boolean) {
  85. if (this._textWrapping === value) {
  86. return;
  87. }
  88. this._textWrapping = +value;
  89. this._markAsDirty();
  90. }
  91. /**
  92. * Gets or sets text to display
  93. */
  94. public get text(): string {
  95. return this._text;
  96. }
  97. /**
  98. * Gets or sets text to display
  99. */
  100. public set text(value: string) {
  101. if (this._text === value) {
  102. return;
  103. }
  104. this._text = value;
  105. this._markAsDirty();
  106. this.onTextChangedObservable.notifyObservers(this);
  107. }
  108. /**
  109. * Gets or sets text horizontal alignment (BABYLON.GUI.Control.HORIZONTAL_ALIGNMENT_CENTER by default)
  110. */
  111. public get textHorizontalAlignment(): number {
  112. return this._textHorizontalAlignment;
  113. }
  114. /**
  115. * Gets or sets text horizontal alignment (BABYLON.GUI.Control.HORIZONTAL_ALIGNMENT_CENTER by default)
  116. */
  117. public set textHorizontalAlignment(value: number) {
  118. if (this._textHorizontalAlignment === value) {
  119. return;
  120. }
  121. this._textHorizontalAlignment = value;
  122. this._markAsDirty();
  123. }
  124. /**
  125. * Gets or sets text vertical alignment (BABYLON.GUI.Control.VERTICAL_ALIGNMENT_CENTER by default)
  126. */
  127. public get textVerticalAlignment(): number {
  128. return this._textVerticalAlignment;
  129. }
  130. /**
  131. * Gets or sets text vertical alignment (BABYLON.GUI.Control.VERTICAL_ALIGNMENT_CENTER by default)
  132. */
  133. public set textVerticalAlignment(value: number) {
  134. if (this._textVerticalAlignment === value) {
  135. return;
  136. }
  137. this._textVerticalAlignment = value;
  138. this._markAsDirty();
  139. }
  140. /**
  141. * Gets or sets line spacing value
  142. */
  143. public set lineSpacing(value: string | number) {
  144. if (this._lineSpacing.fromString(value)) {
  145. this._markAsDirty();
  146. }
  147. }
  148. /**
  149. * Gets or sets line spacing value
  150. */
  151. public get lineSpacing(): string | number {
  152. return this._lineSpacing.toString(this._host);
  153. }
  154. /**
  155. * Gets or sets outlineWidth of the text to display
  156. */
  157. public get outlineWidth(): number {
  158. return this._outlineWidth;
  159. }
  160. /**
  161. * Gets or sets outlineWidth of the text to display
  162. */
  163. public set outlineWidth(value: number) {
  164. if (this._outlineWidth === value) {
  165. return;
  166. }
  167. this._outlineWidth = value;
  168. this._markAsDirty();
  169. }
  170. /**
  171. * Gets or sets outlineColor of the text to display
  172. */
  173. public get outlineColor(): string {
  174. return this._outlineColor;
  175. }
  176. /**
  177. * Gets or sets outlineColor of the text to display
  178. */
  179. public set outlineColor(value: string) {
  180. if (this._outlineColor === value) {
  181. return;
  182. }
  183. this._outlineColor = value;
  184. this._markAsDirty();
  185. }
  186. /**
  187. * Creates a new TextBlock object
  188. * @param name defines the name of the control
  189. * @param text defines the text to display (emptry string by default)
  190. */
  191. constructor(
  192. /**
  193. * Defines the name of the control
  194. */
  195. public name?: string,
  196. text: string = "") {
  197. super(name);
  198. this.text = text;
  199. }
  200. protected _getTypeName(): string {
  201. return "TextBlock";
  202. }
  203. protected _processMeasures(parentMeasure: Measure, context: CanvasRenderingContext2D): void {
  204. if (!this._fontOffset) {
  205. this._fontOffset = Control._GetFontOffset(context.font);
  206. }
  207. super._processMeasures(parentMeasure, context);
  208. // Prepare lines
  209. this._lines = this._breakLines(this._currentMeasure.width, context);
  210. this.onLinesReadyObservable.notifyObservers(this);
  211. let maxLineWidth: number = 0;
  212. for (let i = 0; i < this._lines.length; i++) {
  213. const line = this._lines[i];
  214. if (line.width > maxLineWidth) {
  215. maxLineWidth = line.width;
  216. }
  217. }
  218. if (this._resizeToFit) {
  219. if (this._textWrapping === TextWrapping.Clip) {
  220. let newWidth = this.paddingLeftInPixels + this.paddingRightInPixels + maxLineWidth;
  221. if (newWidth !== this._width.internalValue) {
  222. this._width.updateInPlace(newWidth, ValueAndUnit.UNITMODE_PIXEL);
  223. this._rebuildLayout = true;
  224. }
  225. }
  226. let newHeight = this.paddingTopInPixels + this.paddingBottomInPixels + this._fontOffset.height * this._lines.length;
  227. if (this._lines.length > 0 && this._lineSpacing.internalValue !== 0) {
  228. let lineSpacing = 0;
  229. if (this._lineSpacing.isPixel) {
  230. lineSpacing = this._lineSpacing.getValue(this._host);
  231. } else {
  232. lineSpacing = (this._lineSpacing.getValue(this._host) * this._height.getValueInPixel(this._host, this._cachedParentMeasure.height));
  233. }
  234. newHeight += (this._lines.length - 1) * lineSpacing;
  235. }
  236. if (newHeight !== this._height.internalValue) {
  237. this._height.updateInPlace(newHeight, ValueAndUnit.UNITMODE_PIXEL);
  238. this._rebuildLayout = true;
  239. }
  240. }
  241. }
  242. private _drawText(text: string, textWidth: number, y: number, context: CanvasRenderingContext2D): void {
  243. var width = this._currentMeasure.width;
  244. var x = 0;
  245. switch (this._textHorizontalAlignment) {
  246. case Control.HORIZONTAL_ALIGNMENT_LEFT:
  247. x = 0;
  248. break;
  249. case Control.HORIZONTAL_ALIGNMENT_RIGHT:
  250. x = width - textWidth;
  251. break;
  252. case Control.HORIZONTAL_ALIGNMENT_CENTER:
  253. x = (width - textWidth) / 2;
  254. break;
  255. }
  256. if (this.shadowBlur || this.shadowOffsetX || this.shadowOffsetY) {
  257. context.shadowColor = this.shadowColor;
  258. context.shadowBlur = this.shadowBlur;
  259. context.shadowOffsetX = this.shadowOffsetX;
  260. context.shadowOffsetY = this.shadowOffsetY;
  261. }
  262. if (this.outlineWidth) {
  263. context.strokeText(text, this._currentMeasure.left + x, y);
  264. }
  265. context.fillText(text, this._currentMeasure.left + x, y);
  266. }
  267. /** @hidden */
  268. public _draw(context: CanvasRenderingContext2D, invalidatedRectangle?: Nullable<Measure>): void {
  269. context.save();
  270. this._applyStates(context);
  271. // Render lines
  272. this._renderLines(context);
  273. context.restore();
  274. }
  275. protected _applyStates(context: CanvasRenderingContext2D): void {
  276. super._applyStates(context);
  277. if (this.outlineWidth) {
  278. context.lineWidth = this.outlineWidth;
  279. context.strokeStyle = this.outlineColor;
  280. }
  281. }
  282. protected _breakLines(refWidth: number, context: CanvasRenderingContext2D): object[] {
  283. var lines = [];
  284. var _lines = this.text.split("\n");
  285. if (this._textWrapping === TextWrapping.Ellipsis) {
  286. for (var _line of _lines) {
  287. lines.push(this._parseLineEllipsis(_line, refWidth, context));
  288. }
  289. } else if (this._textWrapping === TextWrapping.WordWrap) {
  290. for (var _line of _lines) {
  291. lines.push(...this._parseLineWordWrap(_line, refWidth, context));
  292. }
  293. } else {
  294. for (var _line of _lines) {
  295. lines.push(this._parseLine(_line, context));
  296. }
  297. }
  298. return lines;
  299. }
  300. protected _parseLine(line: string = '', context: CanvasRenderingContext2D): object {
  301. return { text: line, width: context.measureText(line).width };
  302. }
  303. protected _parseLineEllipsis(line: string = '', width: number,
  304. context: CanvasRenderingContext2D): object {
  305. var lineWidth = context.measureText(line).width;
  306. if (lineWidth > width) {
  307. line += '…';
  308. }
  309. while (line.length > 2 && lineWidth > width) {
  310. line = line.slice(0, -2) + '…';
  311. lineWidth = context.measureText(line).width;
  312. }
  313. return { text: line, width: lineWidth };
  314. }
  315. protected _parseLineWordWrap(line: string = '', width: number,
  316. context: CanvasRenderingContext2D): object[] {
  317. var lines = [];
  318. var words = this.wordSplittingFunction ? this.wordSplittingFunction(line) : line.split(' ');
  319. var lineWidth = 0;
  320. for (var n = 0; n < words.length; n++) {
  321. var testLine = n > 0 ? line + " " + words[n] : words[0];
  322. var metrics = context.measureText(testLine);
  323. var testWidth = metrics.width;
  324. if (testWidth > width && n > 0) {
  325. lines.push({ text: line, width: lineWidth });
  326. line = words[n];
  327. lineWidth = context.measureText(line).width;
  328. }
  329. else {
  330. lineWidth = testWidth;
  331. line = testLine;
  332. }
  333. }
  334. lines.push({ text: line, width: lineWidth });
  335. return lines;
  336. }
  337. protected _renderLines(context: CanvasRenderingContext2D): void {
  338. var height = this._currentMeasure.height;
  339. var rootY = 0;
  340. switch (this._textVerticalAlignment) {
  341. case Control.VERTICAL_ALIGNMENT_TOP:
  342. rootY = this._fontOffset.ascent;
  343. break;
  344. case Control.VERTICAL_ALIGNMENT_BOTTOM:
  345. rootY = height - this._fontOffset.height * (this._lines.length - 1) - this._fontOffset.descent;
  346. break;
  347. case Control.VERTICAL_ALIGNMENT_CENTER:
  348. rootY = this._fontOffset.ascent + (height - this._fontOffset.height * this._lines.length) / 2;
  349. break;
  350. }
  351. rootY += this._currentMeasure.top;
  352. for (let i = 0; i < this._lines.length; i++) {
  353. const line = this._lines[i];
  354. if (i !== 0 && this._lineSpacing.internalValue !== 0) {
  355. if (this._lineSpacing.isPixel) {
  356. rootY += this._lineSpacing.getValue(this._host);
  357. } else {
  358. rootY = rootY + (this._lineSpacing.getValue(this._host) * this._height.getValueInPixel(this._host, this._cachedParentMeasure.height));
  359. }
  360. }
  361. this._drawText(line.text, line.width, rootY, context);
  362. rootY += this._fontOffset.height;
  363. }
  364. }
  365. /**
  366. * Given a width constraint applied on the text block, find the expected height
  367. * @returns expected height
  368. */
  369. public computeExpectedHeight(): number {
  370. if (this.text && this.widthInPixels) {
  371. const context = document.createElement('canvas').getContext('2d');
  372. if (context) {
  373. this._applyStates(context);
  374. if (!this._fontOffset) {
  375. this._fontOffset = Control._GetFontOffset(context.font);
  376. }
  377. const lines = this._lines ? this._lines : this._breakLines(
  378. this.widthInPixels - this.paddingLeftInPixels - this.paddingRightInPixels, context);
  379. let newHeight = this.paddingTopInPixels + this.paddingBottomInPixels + this._fontOffset.height * lines.length;
  380. if (lines.length > 0 && this._lineSpacing.internalValue !== 0) {
  381. let lineSpacing = 0;
  382. if (this._lineSpacing.isPixel) {
  383. lineSpacing = this._lineSpacing.getValue(this._host);
  384. } else {
  385. lineSpacing = (this._lineSpacing.getValue(this._host) * this._height.getValueInPixel(this._host, this._cachedParentMeasure.height));
  386. }
  387. newHeight += (lines.length - 1) * lineSpacing;
  388. }
  389. return newHeight;
  390. }
  391. }
  392. return 0;
  393. }
  394. dispose(): void {
  395. super.dispose();
  396. this.onTextChangedObservable.clear();
  397. }
  398. }
  399. _TypeStore.RegisteredTypes["BABYLON.GUI.TextBlock"] = TextBlock;