textBlock.ts 18 KB

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