UNPKG

visjs-network

Version:

A dynamic, browser-based network visualization library.

646 lines (568 loc) 16.8 kB
let LabelAccumulator = require('./LabelAccumulator').default let ComponentUtil = require('./ComponentUtil').default // Hash of prepared regexp's for tags var tagPattern = { // HTML '<b>': /<b>/, '<i>': /<i>/, '<code>': /<code>/, '</b>': /<\/b>/, '</i>': /<\/i>/, '</code>': /<\/code>/, // Markdown '*': /\*/, // bold _: /\_/, // ital '`': /`/, // mono afterBold: /[^\*]/, afterItal: /[^_]/, afterMono: /[^`]/ } /** * Internal helper class for parsing the markup tags for HTML and Markdown. * * NOTE: Sequences of tabs and spaces are reduced to single space. * Scan usage of `this.spacing` within method */ class MarkupAccumulator { /** * Create an instance * * @param {string} text text to parse for markup */ constructor(text) { this.text = text this.bold = false this.ital = false this.mono = false this.spacing = false this.position = 0 this.buffer = '' this.modStack = [] this.blocks = [] } /** * Return the mod label currently on the top of the stack * * @returns {string} label of topmost mod * @private */ mod() { return this.modStack.length === 0 ? 'normal' : this.modStack[0] } /** * Return the mod label currently active * * @returns {string} label of active mod * @private */ modName() { if (this.modStack.length === 0) return 'normal' else if (this.modStack[0] === 'mono') return 'mono' else { if (this.bold && this.ital) { return 'boldital' } else if (this.bold) { return 'bold' } else if (this.ital) { return 'ital' } } } /** * @private */ emitBlock() { if (this.spacing) { this.add(' ') this.spacing = false } if (this.buffer.length > 0) { this.blocks.push({ text: this.buffer, mod: this.modName() }) this.buffer = '' } } /** * Output text to buffer * * @param {string} text text to add * @private */ add(text) { if (text === ' ') { this.spacing = true } if (this.spacing) { this.buffer += ' ' this.spacing = false } if (text != ' ') { this.buffer += text } } /** * Handle parsing of whitespace * * @param {string} ch the character to check * @returns {boolean} true if the character was processed as whitespace, false otherwise */ parseWS(ch) { if (/[ \t]/.test(ch)) { if (!this.mono) { this.spacing = true } else { this.add(ch) } return true } return false } /** * @param {string} tagName label for block type to set * @private */ setTag(tagName) { this.emitBlock() this[tagName] = true this.modStack.unshift(tagName) } /** * @param {string} tagName label for block type to unset * @private */ unsetTag(tagName) { this.emitBlock() this[tagName] = false this.modStack.shift() } /** * @param {string} tagName label for block type we are currently processing * @param {string|RegExp} tag string to match in text * @returns {boolean} true if the tag was processed, false otherwise */ parseStartTag(tagName, tag) { // Note: if 'mono' passed as tagName, there is a double check here. This is OK if (!this.mono && !this[tagName] && this.match(tag)) { this.setTag(tagName) return true } return false } /** * @param {string|RegExp} tag * @param {number} [advance=true] if set, advance current position in text * @returns {boolean} true if match at given position, false otherwise * @private */ match(tag, advance = true) { let [regExp, length] = this.prepareRegExp(tag) let matched = regExp.test(this.text.substr(this.position, length)) if (matched && advance) { this.position += length - 1 } return matched } /** * @param {string} tagName label for block type we are currently processing * @param {string|RegExp} tag string to match in text * @param {RegExp} [nextTag] regular expression to match for characters *following* the current tag * @returns {boolean} true if the tag was processed, false otherwise */ parseEndTag(tagName, tag, nextTag) { let checkTag = this.mod() === tagName if (tagName === 'mono') { // special handling for 'mono' checkTag = checkTag && this.mono } else { checkTag = checkTag && !this.mono } if (checkTag && this.match(tag)) { if (nextTag !== undefined) { // Purpose of the following match is to prevent a direct unset/set of a given tag // E.g. '*bold **still bold*' => '*bold still bold*' if ( this.position === this.text.length - 1 || this.match(nextTag, false) ) { this.unsetTag(tagName) } } else { this.unsetTag(tagName) } return true } return false } /** * @param {string|RegExp} tag string to match in text * @param {value} value string to replace tag with, if found at current position * @returns {boolean} true if the tag was processed, false otherwise */ replace(tag, value) { if (this.match(tag)) { this.add(value) this.position += length - 1 return true } return false } /** * Create a regular expression for the tag if it isn't already one. * * The return value is an array `[RegExp, number]`, with exactly two value, where: * - RegExp is the regular expression to use * - number is the lenth of the input string to match * * @param {string|RegExp} tag string to match in text * @returns {Array} regular expression to use and length of input string to match * @private */ prepareRegExp(tag) { let length let regExp if (tag instanceof RegExp) { regExp = tag length = 1 // ASSUMPTION: regexp only tests one character } else { // use prepared regexp if present var prepared = tagPattern[tag] if (prepared !== undefined) { regExp = prepared } else { regExp = new RegExp(tag) } length = tag.length } return [regExp, length] } } /** * Helper class for Label which explodes the label text into lines and blocks within lines * * @private */ class LabelSplitter { /** * @param {CanvasRenderingContext2D} ctx Canvas rendering context * @param {Label} parent reference to the Label instance using current instance * @param {boolean} selected * @param {boolean} hover */ constructor(ctx, parent, selected, hover) { this.ctx = ctx this.parent = parent this.selected = selected this.hover = hover /** * Callback to determine text width; passed to LabelAccumulator instance * * @param {String} text string to determine width of * @param {String} mod font type to use for this text * @return {Object} { width, values} width in pixels and font attributes */ let textWidth = (text, mod) => { if (text === undefined) return 0 // TODO: This can be done more efficiently with caching // This will set the ctx.font correctly, depending on selected/hover and mod - so that ctx.measureText() will be accurate. let values = this.parent.getFormattingValues(ctx, selected, hover, mod) let width = 0 if (text !== '') { let measure = this.ctx.measureText(text) width = measure.width } return { width, values: values } } this.lines = new LabelAccumulator(textWidth) } /** * Split passed text of a label into lines and blocks. * * # NOTE * * The handling of spacing is option dependent: * * - if `font.multi : false`, all spaces are retained * - if `font.multi : true`, every sequence of spaces is compressed to a single space * * This might not be the best way to do it, but this is as it has been working till now. * In order not to break existing functionality, for the time being this behaviour will * be retained in any code changes. * * @param {string} text text to split * @returns {Array<line>} */ process(text) { if (!ComponentUtil.isValidLabel(text)) { return this.lines.finalize() } var font = this.parent.fontOptions // Normalize the end-of-line's to a single representation - order important text = text.replace(/\r\n/g, '\n') // Dos EOL's text = text.replace(/\r/g, '\n') // Mac EOL's // Note that at this point, there can be no \r's in the text. // This is used later on splitStringIntoLines() to split multifont texts. let nlLines = String(text).split('\n') let lineCount = nlLines.length if (font.multi) { // Multi-font case: styling tags active for (let i = 0; i < lineCount; i++) { let blocks = this.splitBlocks(nlLines[i], font.multi) // Post: Sequences of tabs and spaces are reduced to single space if (blocks === undefined) continue if (blocks.length === 0) { this.lines.newLine('') continue } if (font.maxWdt > 0) { // widthConstraint.maximum defined //console.log('Running widthConstraint multi, max: ' + this.fontOptions.maxWdt); for (let j = 0; j < blocks.length; j++) { let mod = blocks[j].mod let text = blocks[j].text this.splitStringIntoLines(text, mod, true) } } else { // widthConstraint.maximum NOT defined for (let j = 0; j < blocks.length; j++) { let mod = blocks[j].mod let text = blocks[j].text this.lines.append(text, mod) } } this.lines.newLine() } } else { // Single-font case if (font.maxWdt > 0) { // widthConstraint.maximum defined // console.log('Running widthConstraint normal, max: ' + this.fontOptions.maxWdt); for (let i = 0; i < lineCount; i++) { this.splitStringIntoLines(nlLines[i]) } } else { // widthConstraint.maximum NOT defined for (let i = 0; i < lineCount; i++) { this.lines.newLine(nlLines[i]) } } } return this.lines.finalize() } /** * normalize the markup system * * @param {boolean|'md'|'markdown'|'html'} markupSystem * @returns {string} */ decodeMarkupSystem(markupSystem) { let system = 'none' if (markupSystem === 'markdown' || markupSystem === 'md') { system = 'markdown' } else if (markupSystem === true || markupSystem === 'html') { system = 'html' } return system } /** * * @param {string} text * @returns {Array} */ splitHtmlBlocks(text) { let s = new MarkupAccumulator(text) let parseEntities = ch => { if (/&/.test(ch)) { let parsed = s.replace(s.text, '&lt;', '<') || s.replace(s.text, '&amp;', '&') if (!parsed) { s.add('&') } return true } return false } while (s.position < s.text.length) { let ch = s.text.charAt(s.position) let parsed = s.parseWS(ch) || (/</.test(ch) && (s.parseStartTag('bold', '<b>') || s.parseStartTag('ital', '<i>') || s.parseStartTag('mono', '<code>') || s.parseEndTag('bold', '</b>') || s.parseEndTag('ital', '</i>') || s.parseEndTag('mono', '</code>'))) || parseEntities(ch) if (!parsed) { s.add(ch) } s.position++ } s.emitBlock() return s.blocks } /** * * @param {string} text * @returns {Array} */ splitMarkdownBlocks(text) { let s = new MarkupAccumulator(text) let beginable = true let parseOverride = ch => { if (/\\/.test(ch)) { if (s.position < this.text.length + 1) { s.position++ ch = this.text.charAt(s.position) if (/ \t/.test(ch)) { s.spacing = true } else { s.add(ch) beginable = false } } return true } return false } while (s.position < s.text.length) { let ch = s.text.charAt(s.position) let parsed = s.parseWS(ch) || parseOverride(ch) || ((beginable || s.spacing) && (s.parseStartTag('bold', '*') || s.parseStartTag('ital', '_') || s.parseStartTag('mono', '`'))) || s.parseEndTag('bold', '*', 'afterBold') || s.parseEndTag('ital', '_', 'afterItal') || s.parseEndTag('mono', '`', 'afterMono') if (!parsed) { s.add(ch) beginable = false } s.position++ } s.emitBlock() return s.blocks } /** * Explodes a piece of text into single-font blocks using a given markup * * @param {string} text * @param {boolean|'md'|'markdown'|'html'} markupSystem * @returns {Array.<{text: string, mod: string}>} * @private */ splitBlocks(text, markupSystem) { let system = this.decodeMarkupSystem(markupSystem) if (system === 'none') { return [ { text: text, mod: 'normal' } ] } else if (system === 'markdown') { return this.splitMarkdownBlocks(text) } else if (system === 'html') { return this.splitHtmlBlocks(text) } } /** * @param {string} text * @returns {boolean} true if text length over the current max with * @private */ overMaxWidth(text) { let width = this.ctx.measureText(text).width return this.lines.curWidth() + width > this.parent.fontOptions.maxWdt } /** * Determine the longest part of the sentence which still fits in the * current max width. * * @param {Array} words Array of strings signifying a text lines * @return {number} index of first item in string making string go over max * @private */ getLongestFit(words) { let text = '' let w = 0 while (w < words.length) { let pre = text === '' ? '' : ' ' let newText = text + pre + words[w] if (this.overMaxWidth(newText)) break text = newText w++ } return w } /** * Determine the longest part of the string which still fits in the * current max width. * * @param {Array} words Array of strings signifying a text lines * @return {number} index of first item in string making string go over max */ getLongestFitWord(words) { let w = 0 while (w < words.length) { if (this.overMaxWidth(words.slice(0, w))) break w++ } return w } /** * Split the passed text into lines, according to width constraint (if any). * * The method assumes that the input string is a single line, i.e. without lines break. * * This method retains spaces, if still present (case `font.multi: false`). * A space which falls on an internal line break, will be replaced by a newline. * There is no special handling of tabs; these go along with the flow. * * @param {string} str * @param {string} [mod='normal'] * @param {boolean} [appendLast=false] * @private */ splitStringIntoLines(str, mod = 'normal', appendLast = false) { // Set the canvas context font, based upon the current selected/hover state // and the provided mod, so the text measurement performed by getLongestFit // will be accurate - and not just use the font of whoever last used the canvas. this.parent.getFormattingValues(this.ctx, this.selected, this.hover, mod) // Still-present spaces are relevant, retain them str = str.replace(/^( +)/g, '$1\r') str = str.replace(/([^\r][^ ]*)( +)/g, '$1\r$2\r') let words = str.split('\r') while (words.length > 0) { let w = this.getLongestFit(words) if (w === 0) { // Special case: the first word is already larger than the max width. let word = words[0] // Break the word to the largest part that fits the line let x = this.getLongestFitWord(word) this.lines.newLine(word.slice(0, x), mod) // Adjust the word, so that the rest will be done next iteration words[0] = word.slice(x) } else { // skip any space that is replaced by a newline let newW = w if (words[w - 1] === ' ') { w-- } else if (words[newW] === ' ') { newW++ } let text = words.slice(0, w).join('') if (w == words.length && appendLast) { this.lines.append(text, mod) } else { this.lines.newLine(text, mod) } // Adjust the word, so that the rest will be done next iteration words = words.slice(newW) } } } } export default LabelSplitter