vis.js is a dynamic, browser-based visualization library
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

546 lines
15 KiB

  1. let LabelAccumulator = require('./LabelAccumulator').default;
  2. /**
  3. * Helper class for Label which explodes the label text into lines and blocks within lines
  4. *
  5. * @private
  6. */
  7. class LabelSplitter {
  8. /**
  9. * @param {CanvasRenderingContext2D} ctx Canvas rendering context
  10. * @param {Label} parent reference to the Label instance using current instance
  11. * @param {boolean} selected
  12. * @param {boolean} hover
  13. */
  14. constructor(ctx, parent, selected, hover) {
  15. this.ctx = ctx;
  16. this.parent = parent;
  17. /**
  18. * Callback to determine text width; passed to LabelAccumulator instance
  19. *
  20. * @param {String} text string to determine width of
  21. * @param {String} mod font type to use for this text
  22. * @return {Object} { width, values} width in pixels and font attributes
  23. */
  24. let textWidth = (text, mod) => {
  25. if (text === undefined) return 0;
  26. // TODO: This can be done more efficiently with caching
  27. let values = this.parent.getFormattingValues(ctx, selected, hover, mod);
  28. let width = 0;
  29. if (text !== '') {
  30. // NOTE: The following may actually be *incorrect* for the mod fonts!
  31. // This returns the size with a regular font, bold etc. may
  32. // have different sizes.
  33. let measure = this.ctx.measureText(text);
  34. width = measure.width;
  35. }
  36. return {width, values: values};
  37. };
  38. this.lines = new LabelAccumulator(textWidth);
  39. }
  40. /**
  41. * Split passed text of a label into lines and blocks.
  42. *
  43. * # NOTE
  44. *
  45. * The handling of spacing is option dependent:
  46. *
  47. * - if `font.multi : false`, all spaces are retained
  48. * - if `font.multi : true`, every sequence of spaces is compressed to a single space
  49. *
  50. * This might not be the best way to do it, but this is as it has been working till now.
  51. * In order not to break existing functionality, for the time being this behaviour will
  52. * be retained in any code changes.
  53. *
  54. * @param {string} text text to split
  55. * @returns {Array<line>}
  56. */
  57. process(text) {
  58. if (text === undefined || text === "") {
  59. return this.lines.finalize();
  60. }
  61. // Normalize the end-of-line's to a single representation - order important
  62. text = text.replace(/\r\n/g, '\n'); // Dos EOL's
  63. text = text.replace(/\r/g, '\n'); // Mac EOL's
  64. // Note that at this point, there can be no \r's in the text.
  65. // This is used later on splitStringIntoLines() to split multifont texts.
  66. let nlLines = String(text).split('\n');
  67. let lineCount = nlLines.length;
  68. if (this.parent.elementOptions.font.multi) {
  69. // Multi-font case: styling tags active
  70. for (let i = 0; i < lineCount; i++) {
  71. let blocks = this.splitBlocks(nlLines[i], this.parent.elementOptions.font.multi);
  72. // Post: Sequences of tabs and spaces are reduced to single space
  73. if (blocks === undefined) continue;
  74. if (blocks.length === 0) {
  75. this.lines.newLine("");
  76. continue;
  77. }
  78. if (this.parent.fontOptions.maxWdt > 0) {
  79. // widthConstraint.maximum defined
  80. //console.log('Running widthConstraint multi, max: ' + this.fontOptions.maxWdt);
  81. for (let j = 0; j < blocks.length; j++) {
  82. let mod = blocks[j].mod;
  83. let text = blocks[j].text;
  84. this.splitStringIntoLines(text, mod, true);
  85. }
  86. } else {
  87. // widthConstraint.maximum NOT defined
  88. for (let j = 0; j < blocks.length; j++) {
  89. let mod = blocks[j].mod;
  90. let text = blocks[j].text;
  91. this.lines.append(text, mod);
  92. }
  93. }
  94. this.lines.newLine();
  95. }
  96. } else {
  97. // Single-font case
  98. if (this.parent.fontOptions.maxWdt > 0) {
  99. // widthConstraint.maximum defined
  100. // console.log('Running widthConstraint normal, max: ' + this.fontOptions.maxWdt);
  101. for (let i = 0; i < lineCount; i++) {
  102. this.splitStringIntoLines(nlLines[i]);
  103. }
  104. } else {
  105. // widthConstraint.maximum NOT defined
  106. for (let i = 0; i < lineCount; i++) {
  107. this.lines.newLine(nlLines[i]);
  108. }
  109. }
  110. }
  111. return this.lines.finalize();
  112. }
  113. /**
  114. * normalize the markup system
  115. *
  116. * @param {boolean|'md'|'markdown'|'html'} markupSystem
  117. * @returns {string}
  118. */
  119. decodeMarkupSystem(markupSystem) {
  120. let system = 'none';
  121. if (markupSystem === 'markdown' || markupSystem === 'md') {
  122. system = 'markdown';
  123. } else if (markupSystem === true || markupSystem === 'html') {
  124. system = 'html'
  125. }
  126. return system;
  127. }
  128. /**
  129. *
  130. * @param {string} text
  131. * @returns {Array}
  132. */
  133. splitHtmlBlocks(text) {
  134. let blocks = [];
  135. // TODO: consolidate following + methods/closures with splitMarkdownBlocks()
  136. // NOTE: sequences of tabs and spaces are reduced to single space; scan usage of `this.spacing` within method
  137. let s = {
  138. bold: false,
  139. ital: false,
  140. mono: false,
  141. spacing: false,
  142. position: 0,
  143. buffer: "",
  144. modStack: []
  145. };
  146. s.mod = function() {
  147. return (this.modStack.length === 0) ? 'normal' : this.modStack[0];
  148. };
  149. s.modName = function() {
  150. if (this.modStack.length === 0)
  151. return 'normal';
  152. else if (this.modStack[0] === 'mono')
  153. return 'mono';
  154. else {
  155. if (s.bold && s.ital) {
  156. return 'boldital';
  157. } else if (s.bold) {
  158. return 'bold';
  159. } else if (s.ital) {
  160. return 'ital';
  161. }
  162. }
  163. };
  164. s.emitBlock = function(override=false) { // eslint-disable-line no-unused-vars
  165. if (this.spacing) {
  166. this.add(" ");
  167. this.spacing = false;
  168. }
  169. if (this.buffer.length > 0) {
  170. blocks.push({ text: this.buffer, mod: this.modName() });
  171. this.buffer = "";
  172. }
  173. };
  174. s.add = function(text) {
  175. if (text === " ") {
  176. s.spacing = true;
  177. }
  178. if (s.spacing) {
  179. this.buffer += " ";
  180. this.spacing = false;
  181. }
  182. if (text != " ") {
  183. this.buffer += text;
  184. }
  185. };
  186. while (s.position < text.length) {
  187. let ch = text.charAt(s.position);
  188. if (/[ \t]/.test(ch)) {
  189. if (!s.mono) {
  190. s.spacing = true;
  191. } else {
  192. s.add(ch);
  193. }
  194. } else if (/</.test(ch)) {
  195. if (!s.mono && !s.bold && /<b>/.test(text.substr(s.position,3))) {
  196. s.emitBlock();
  197. s.bold = true;
  198. s.modStack.unshift("bold");
  199. s.position += 2;
  200. } else if (!s.mono && !s.ital && /<i>/.test(text.substr(s.position,3))) {
  201. s.emitBlock();
  202. s.ital = true;
  203. s.modStack.unshift("ital");
  204. s.position += 2;
  205. } else if (!s.mono && /<code>/.test(text.substr(s.position,6))) {
  206. s.emitBlock();
  207. s.mono = true;
  208. s.modStack.unshift("mono");
  209. s.position += 5;
  210. } else if (!s.mono && (s.mod() === 'bold') && /<\/b>/.test(text.substr(s.position,4))) {
  211. s.emitBlock();
  212. s.bold = false;
  213. s.modStack.shift();
  214. s.position += 3;
  215. } else if (!s.mono && (s.mod() === 'ital') && /<\/i>/.test(text.substr(s.position,4))) {
  216. s.emitBlock();
  217. s.ital = false;
  218. s.modStack.shift();
  219. s.position += 3;
  220. } else if ((s.mod() === 'mono') && /<\/code>/.test(text.substr(s.position,7))) {
  221. s.emitBlock();
  222. s.mono = false;
  223. s.modStack.shift();
  224. s.position += 6;
  225. } else {
  226. s.add(ch);
  227. }
  228. } else if (/&/.test(ch)) {
  229. if (/&lt;/.test(text.substr(s.position,4))) {
  230. s.add("<");
  231. s.position += 3;
  232. } else if (/&amp;/.test(text.substr(s.position,5))) {
  233. s.add("&");
  234. s.position += 4;
  235. } else {
  236. s.add("&");
  237. }
  238. } else {
  239. s.add(ch);
  240. }
  241. s.position++
  242. }
  243. s.emitBlock();
  244. return blocks;
  245. }
  246. /**
  247. *
  248. * @param {string} text
  249. * @returns {Array}
  250. */
  251. splitMarkdownBlocks(text) {
  252. let blocks = [];
  253. // TODO: consolidate following + methods/closures with splitHtmlBlocks()
  254. // NOTE: sequences of tabs and spaces are reduced to single space; scan usage of `this.spacing` within method
  255. let s = {
  256. bold: false,
  257. ital: false,
  258. mono: false,
  259. beginable: true,
  260. spacing: false,
  261. position: 0,
  262. buffer: "",
  263. modStack: []
  264. };
  265. s.mod = function() {
  266. return (this.modStack.length === 0) ? 'normal' : this.modStack[0];
  267. };
  268. s.modName = function() {
  269. if (this.modStack.length === 0)
  270. return 'normal';
  271. else if (this.modStack[0] === 'mono')
  272. return 'mono';
  273. else {
  274. if (s.bold && s.ital) {
  275. return 'boldital';
  276. } else if (s.bold) {
  277. return 'bold';
  278. } else if (s.ital) {
  279. return 'ital';
  280. }
  281. }
  282. };
  283. s.emitBlock = function(override=false) { // eslint-disable-line no-unused-vars
  284. if (this.spacing) {
  285. this.add(" ");
  286. this.spacing = false;
  287. }
  288. if (this.buffer.length > 0) {
  289. blocks.push({ text: this.buffer, mod: this.modName() });
  290. this.buffer = "";
  291. }
  292. };
  293. s.add = function(text) {
  294. if (text === " ") {
  295. s.spacing = true;
  296. }
  297. if (s.spacing) {
  298. this.buffer += " ";
  299. this.spacing = false;
  300. }
  301. if (text != " ") {
  302. this.buffer += text;
  303. }
  304. };
  305. while (s.position < text.length) {
  306. let ch = text.charAt(s.position);
  307. if (/[ \t]/.test(ch)) {
  308. if (!s.mono) {
  309. s.spacing = true;
  310. } else {
  311. s.add(ch);
  312. }
  313. s.beginable = true
  314. } else if (/\\/.test(ch)) {
  315. if (s.position < text.length+1) {
  316. s.position++;
  317. ch = text.charAt(s.position);
  318. if (/ \t/.test(ch)) {
  319. s.spacing = true;
  320. } else {
  321. s.add(ch);
  322. s.beginable = false;
  323. }
  324. }
  325. } else if (!s.mono && !s.bold && (s.beginable || s.spacing) && /\*/.test(ch)) {
  326. s.emitBlock();
  327. s.bold = true;
  328. s.modStack.unshift("bold");
  329. } else if (!s.mono && !s.ital && (s.beginable || s.spacing) && /\_/.test(ch)) {
  330. s.emitBlock();
  331. s.ital = true;
  332. s.modStack.unshift("ital");
  333. } else if (!s.mono && (s.beginable || s.spacing) && /`/.test(ch)) {
  334. s.emitBlock();
  335. s.mono = true;
  336. s.modStack.unshift("mono");
  337. } else if (!s.mono && (s.mod() === "bold") && /\*/.test(ch)) {
  338. if ((s.position === text.length-1) || /[.,_` \t\n]/.test(text.charAt(s.position+1))) {
  339. s.emitBlock();
  340. s.bold = false;
  341. s.modStack.shift();
  342. } else {
  343. s.add(ch);
  344. }
  345. } else if (!s.mono && (s.mod() === "ital") && /\_/.test(ch)) {
  346. if ((s.position === text.length-1) || /[.,*` \t\n]/.test(text.charAt(s.position+1))) {
  347. s.emitBlock();
  348. s.ital = false;
  349. s.modStack.shift();
  350. } else {
  351. s.add(ch);
  352. }
  353. } else if (s.mono && (s.mod() === "mono") && /`/.test(ch)) {
  354. if ((s.position === text.length-1) || (/[.,*_ \t\n]/.test(text.charAt(s.position+1)))) {
  355. s.emitBlock();
  356. s.mono = false;
  357. s.modStack.shift();
  358. } else {
  359. s.add(ch);
  360. }
  361. } else {
  362. s.add(ch);
  363. s.beginable = false;
  364. }
  365. s.position++
  366. }
  367. s.emitBlock();
  368. return blocks;
  369. }
  370. /**
  371. * Explodes a piece of text into single-font blocks using a given markup
  372. *
  373. * @param {string} text
  374. * @param {boolean|'md'|'markdown'|'html'} markupSystem
  375. * @returns {Array.<{text: string, mod: string}>}
  376. * @private
  377. */
  378. splitBlocks(text, markupSystem) {
  379. let system = this.decodeMarkupSystem(markupSystem);
  380. if (system === 'none') {
  381. return [{
  382. text: text,
  383. mod: 'normal'
  384. }]
  385. } else if (system === 'markdown') {
  386. return this.splitMarkdownBlocks(text);
  387. } else if (system === 'html') {
  388. return this.splitHtmlBlocks(text);
  389. }
  390. }
  391. /**
  392. * @param {string} text
  393. * @returns {boolean} true if text length over the current max with
  394. * @private
  395. */
  396. overMaxWidth(text) {
  397. let width = this.ctx.measureText(text).width;
  398. return (this.lines.curWidth() + width > this.parent.fontOptions.maxWdt);
  399. }
  400. /**
  401. * Determine the longest part of the sentence which still fits in the
  402. * current max width.
  403. *
  404. * @param {Array} words Array of strings signifying a text lines
  405. * @return {number} index of first item in string making string go over max
  406. * @private
  407. */
  408. getLongestFit(words) {
  409. let text = '';
  410. let w = 0;
  411. while (w < words.length) {
  412. let pre = (text === '') ? '' : ' ';
  413. let newText = text + pre + words[w];
  414. if (this.overMaxWidth(newText)) break;
  415. text = newText;
  416. w++;
  417. }
  418. return w;
  419. }
  420. /**
  421. * Determine the longest part of the string which still fits in the
  422. * current max width.
  423. *
  424. * @param {Array} words Array of strings signifying a text lines
  425. * @return {number} index of first item in string making string go over max
  426. */
  427. getLongestFitWord(words) {
  428. let w = 0;
  429. while (w < words.length) {
  430. if (this.overMaxWidth(words.slice(0,w))) break;
  431. w++;
  432. }
  433. return w;
  434. }
  435. /**
  436. * Split the passed text into lines, according to width constraint (if any).
  437. *
  438. * The method assumes that the input string is a single line, i.e. without lines break.
  439. *
  440. * This method retains spaces, if still present (case `font.multi: false`).
  441. * A space which falls on an internal line break, will be replaced by a newline.
  442. * There is no special handling of tabs; these go along with the flow.
  443. *
  444. * @param {string} str
  445. * @param {string} [mod='normal']
  446. * @param {boolean} [appendLast=false]
  447. * @private
  448. */
  449. splitStringIntoLines(str, mod = 'normal', appendLast = false) {
  450. // Still-present spaces are relevant, retain them
  451. str = str.replace(/^( +)/g, '$1\r');
  452. str = str.replace(/([^\r][^ ]*)( +)/g, '$1\r$2\r');
  453. let words = str.split('\r');
  454. while (words.length > 0) {
  455. let w = this.getLongestFit(words);
  456. if (w === 0) {
  457. // Special case: the first word is already larger than the max width.
  458. let word = words[0];
  459. // Break the word to the largest part that fits the line
  460. let x = this.getLongestFitWord(word);
  461. this.lines.newLine(word.slice(0, x), mod);
  462. // Adjust the word, so that the rest will be done next iteration
  463. words[0] = word.slice(x);
  464. } else {
  465. // skip any space that is replaced by a newline
  466. let newW = w;
  467. if (words[w - 1] === ' ') {
  468. w--;
  469. } else if (words[newW] === ' ') {
  470. newW++;
  471. }
  472. let text = words.slice(0, w).join("");
  473. if (w == words.length && appendLast) {
  474. this.lines.append(text, mod);
  475. } else {
  476. this.lines.newLine(text, mod);
  477. }
  478. // Adjust the word, so that the rest will be done next iteration
  479. words = words.slice(newW);
  480. }
  481. }
  482. }
  483. }
  484. export default LabelSplitter;