399 lines
12 KiB
JavaScript
399 lines
12 KiB
JavaScript
|
/**
|
||
|
* Defines usefull default text styles.
|
||
|
*/
|
||
|
export class FontInfo {
|
||
|
static get small() {
|
||
|
return app.theme.textStyleSmall
|
||
|
}
|
||
|
|
||
|
static get normal() {
|
||
|
return app.theme.textStyle
|
||
|
}
|
||
|
|
||
|
static get centered() {
|
||
|
return Object.assign({}, app.theme.textStyle, { align: 'center' })
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Static methods to support hyphenation of lines.
|
||
|
*
|
||
|
* @class Hypenate
|
||
|
*/
|
||
|
export class Hypenate {
|
||
|
static splitPart(part) {
|
||
|
let parts = part.split('-')
|
||
|
if (parts.length == 1) return [part]
|
||
|
let result = []
|
||
|
let last = parts.pop()
|
||
|
for (let p of parts) {
|
||
|
result.push(p + '-')
|
||
|
}
|
||
|
result.push(last)
|
||
|
return result.filter(p => p.length > 0)
|
||
|
}
|
||
|
|
||
|
static splitWord(word) {
|
||
|
if (typeof language == 'undefined') {
|
||
|
if (word.indexOf('-') > -1) {
|
||
|
return word.split('-')
|
||
|
}
|
||
|
return [word]
|
||
|
}
|
||
|
let parts = language.hyphenate(word)
|
||
|
let result = []
|
||
|
for (let part of parts) {
|
||
|
for (let splitted of this.splitPart(part)) {
|
||
|
result.push(splitted)
|
||
|
}
|
||
|
}
|
||
|
return result
|
||
|
}
|
||
|
|
||
|
static abbreviateLine(label, style, width) {
|
||
|
const pixiStyle = new PIXI.TextStyle(style)
|
||
|
let metrics = PIXI.TextMetrics.measureText(label, pixiStyle)
|
||
|
while (metrics.width > width && label.length > 3) {
|
||
|
label = label.slice(0, label.length - 1)
|
||
|
metrics = PIXI.TextMetrics.measureText(label, pixiStyle)
|
||
|
}
|
||
|
label = label.slice(0, label.length - 1)
|
||
|
return label + '…'
|
||
|
}
|
||
|
|
||
|
static splitLine(line, pixiStyle, width, space, minus) {
|
||
|
let x = 0
|
||
|
let result = ''
|
||
|
let words = line.split(' ')
|
||
|
for (let word of words) {
|
||
|
let wordMetrics = PIXI.TextMetrics.measureText(word, pixiStyle)
|
||
|
if (x + wordMetrics.width >= width) {
|
||
|
let parts = this.splitWord(word)
|
||
|
let newWord = ''
|
||
|
if (parts.length == 1) {
|
||
|
newWord += '\n' + word + ' '
|
||
|
x = wordMetrics.width + space.width
|
||
|
} else {
|
||
|
let first = true
|
||
|
let lastPart = ''
|
||
|
for (let part of parts) {
|
||
|
let partMetrics = PIXI.TextMetrics.measureText(part, pixiStyle)
|
||
|
if (x + partMetrics.width + space.width > width) {
|
||
|
newWord += (first || lastPart.endsWith('-') ? '\n' : '-\n') + part
|
||
|
x = partMetrics.width
|
||
|
} else {
|
||
|
newWord += part
|
||
|
x += partMetrics.width
|
||
|
}
|
||
|
lastPart = part
|
||
|
first = false
|
||
|
}
|
||
|
x += space.width
|
||
|
}
|
||
|
result += newWord + ' '
|
||
|
} else {
|
||
|
result += word + ' '
|
||
|
x += wordMetrics.width + space.width
|
||
|
}
|
||
|
}
|
||
|
return result
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Main method and entry point for text hyphenation
|
||
|
*
|
||
|
* @static
|
||
|
* @param {*} text
|
||
|
* @param {*} style
|
||
|
* @param {*} width
|
||
|
* @memberof Hypenate
|
||
|
* @returns {string}
|
||
|
*/
|
||
|
static splitLines(text, style, width) {
|
||
|
const pixiStyle = new PIXI.TextStyle(style)
|
||
|
const lines = text.split('\n')
|
||
|
const space = PIXI.TextMetrics.measureText(' ', pixiStyle)
|
||
|
const minus = PIXI.TextMetrics.measureText('-', pixiStyle)
|
||
|
let result = []
|
||
|
for (let line of lines) {
|
||
|
result.push(this.splitLine(line, pixiStyle, width, space, minus))
|
||
|
}
|
||
|
return result.join('\n')
|
||
|
}
|
||
|
}
|
||
|
|
||
|
class TextLabel extends PIXI.Text {
|
||
|
/**
|
||
|
*Creates an instance of TextLabel.
|
||
|
* @param {string} text - The string that you would like the text to display
|
||
|
* @param {object|PIXI.TextStyle} [style] - The style parameters
|
||
|
* @param {canvas}
|
||
|
* @memberof TextLabel
|
||
|
*/
|
||
|
constructor(text, style = null, canvas = null, { minZoom = 0.1, maxZoom = 10 } = {}) {
|
||
|
super(text, style, canvas)
|
||
|
this.normFontSize = this.style.fontSize
|
||
|
this.minZoom = minZoom
|
||
|
this.maxZoom = maxZoom
|
||
|
}
|
||
|
|
||
|
zoom(factor) {
|
||
|
let oldValue = parseFloat(this.style.fontSize) / this.normFontSize
|
||
|
let value = oldValue * factor
|
||
|
this.setZoom(value)
|
||
|
}
|
||
|
|
||
|
setZoom(value) {
|
||
|
let oldValue = parseFloat(this.style.fontSize) / this.normFontSize
|
||
|
if (value > this.maxZoom) {
|
||
|
value = this.maxZoom
|
||
|
}
|
||
|
if (value < this.minZoom) {
|
||
|
value = this.minZoom
|
||
|
}
|
||
|
if (value != oldValue) {
|
||
|
this.style.fontSize = Math.max(value * this.normFontSize, 1)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
setZoomAndScale(scale) {
|
||
|
this.scale.set(1 / scale)
|
||
|
this.setZoom(scale)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* A specialization of the PIXI.Graphics class that allows to
|
||
|
* resuse and place labels across different layout variants
|
||
|
*
|
||
|
* @export
|
||
|
* @class LabeledGraphics
|
||
|
* @extends {PIXI.Graphics}
|
||
|
*/
|
||
|
export class LabeledGraphics extends PIXI.Graphics {
|
||
|
/**
|
||
|
* Creates an instance of LabeledGraphics and defines a local label cache.
|
||
|
*
|
||
|
* @memberof LabeledGraphics
|
||
|
*/
|
||
|
constructor() {
|
||
|
super()
|
||
|
this.labels = new Map()
|
||
|
}
|
||
|
|
||
|
_createText(label, fontInfo) {
|
||
|
return new TextLabel(label, fontInfo)
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Main additional method. Ensures that a text object is created that is cached
|
||
|
* under the given key.
|
||
|
*
|
||
|
* @param {*} key - The cache key
|
||
|
* @param {*} label - The label to show
|
||
|
* @param {*} [attrs={}] - Defines attributes of the text object.
|
||
|
* align: 'right', 'left', or 'center'
|
||
|
* justify: 'top', 'bottom', or 'center'
|
||
|
* maxLines: {integer} truncates the text and adds ellipsis
|
||
|
* maxHeight: {number} truncates text that needs more space and adds ellipsis
|
||
|
* maxWidth: {number} word wraps text using hyphenation if possible
|
||
|
* @param {*} [fontInfo=FontInfo.normal] - Defines PIXI.TextStyle attributes
|
||
|
* @returns {PIXI.Text} - instance
|
||
|
* @memberof LabeledGraphics
|
||
|
*/
|
||
|
ensureLabel(key, label, attrs = {}, fontInfo = FontInfo.normal) {
|
||
|
if (attrs.maxWidth && attrs.maxLines == 1) {
|
||
|
label = Hypenate.abbreviateLine(label, fontInfo, attrs.maxWidth)
|
||
|
} else {
|
||
|
if (attrs.maxWidth) {
|
||
|
label = Hypenate.splitLines(label, fontInfo, attrs.maxWidth)
|
||
|
}
|
||
|
if (attrs.maxLines) {
|
||
|
label = this.truncateLabel(label, fontInfo, attrs.maxLines)
|
||
|
}
|
||
|
if (attrs.maxHeight) {
|
||
|
let styleInfo = new PIXI.TextStyle(fontInfo)
|
||
|
let metrics = PIXI.TextMetrics.measureText(label, styleInfo)
|
||
|
let maxLines = Math.max(attrs.maxHeight / metrics.lineHeight, 1)
|
||
|
label = this.truncateLabel(label, fontInfo, maxLines)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (!this.labels.has(key)) {
|
||
|
let text = this._createText(label, fontInfo)
|
||
|
this.labels.set(key, text)
|
||
|
this.addChild(text)
|
||
|
}
|
||
|
let text = this.labels.get(key)
|
||
|
for (let k in attrs) {
|
||
|
text[k] = attrs[k]
|
||
|
}
|
||
|
if (label != text.text) text.text = label
|
||
|
// We do not follow the flexbox jargon and use align for x and justify for y axis
|
||
|
// This deviation is needed to ensure backward compatability
|
||
|
switch (attrs.justify || null) {
|
||
|
case 'top':
|
||
|
text.anchor.y = 0
|
||
|
break
|
||
|
case 'bottom':
|
||
|
text.anchor.x = 1
|
||
|
break
|
||
|
default:
|
||
|
text.anchor.y = 0.5
|
||
|
break
|
||
|
}
|
||
|
switch (attrs.align) {
|
||
|
case 'right':
|
||
|
text.anchor.x = 1
|
||
|
break
|
||
|
case 'center':
|
||
|
text.anchor.x = 0.5
|
||
|
break
|
||
|
default:
|
||
|
text.anchor.x = 0
|
||
|
break
|
||
|
}
|
||
|
text.visible = true
|
||
|
return text
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Private method that truncates the text and adds an ellipsis if there are more lines
|
||
|
* than wanted
|
||
|
*
|
||
|
* @param {*} text
|
||
|
* @param {*} style
|
||
|
* @param {*} [maxLines=Infinity]
|
||
|
* @returns {string}
|
||
|
* @memberof LabeledGraphics
|
||
|
*/
|
||
|
truncateLabel(text, style, maxLines = Infinity) {
|
||
|
if (maxLines === Infinity) {
|
||
|
return text
|
||
|
}
|
||
|
const { wordWrapWidth } = style
|
||
|
const pixiStyle = new PIXI.TextStyle(style)
|
||
|
const { lines } = PIXI.TextMetrics.measureText(text, pixiStyle)
|
||
|
let newText = text
|
||
|
if (lines.length > maxLines) {
|
||
|
const truncatedLines = lines.slice(0, maxLines)
|
||
|
const lastLine = truncatedLines[truncatedLines.length - 1]
|
||
|
const words = lastLine.split(' ')
|
||
|
const wordMetrics = PIXI.TextMetrics.measureText(`\u00A0\n...\n${words.join('\n')}`, pixiStyle)
|
||
|
const [spaceLength, dotsLength, ...wordLengths] = wordMetrics.lineWidths
|
||
|
const { text: newLastLine } = wordLengths.reduce(
|
||
|
(data, wordLength, i) => {
|
||
|
if (data.length + wordLength + spaceLength >= wordWrapWidth) {
|
||
|
return { ...data, length: wordWrapWidth }
|
||
|
}
|
||
|
return {
|
||
|
text: `${data.text}${i > 0 ? ' ' : ''}${words[i]}`,
|
||
|
length: data.length + wordLength + spaceLength
|
||
|
}
|
||
|
},
|
||
|
{ text: '', length: dotsLength }
|
||
|
)
|
||
|
truncatedLines[truncatedLines.length - 1] = `${newLastLine}...`
|
||
|
newText = truncatedLines.join('\n')
|
||
|
}
|
||
|
return newText
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Returns the label for the given key.
|
||
|
*
|
||
|
* @param {*} key
|
||
|
* @returns {Object}
|
||
|
* @memberof LabeledGraphics
|
||
|
*/
|
||
|
getLabel(key) {
|
||
|
return this.labels.get(key)
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Hides the label with the given key.
|
||
|
* @param {*} key
|
||
|
* @memberof LabeledGraphics
|
||
|
*/
|
||
|
hideLabel(key) {
|
||
|
let label = this.labels.get(key)
|
||
|
if (label) {
|
||
|
label.visible = false
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Removes the label with the given key.
|
||
|
* @param {*} key
|
||
|
* @memberof LabeledGraphics
|
||
|
*/
|
||
|
removeLabel(key) {
|
||
|
let label = this.labels.get(key)
|
||
|
this.labels.delete(key)
|
||
|
label.destroy()
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Ensures that labels are hidden on clear.
|
||
|
*
|
||
|
* @memberof LabeledGraphics
|
||
|
*/
|
||
|
clear() {
|
||
|
super.clear()
|
||
|
for (let key of this.labels.keys()) {
|
||
|
this.hideLabel(key)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Logs debugging infos
|
||
|
*
|
||
|
* @memberof LabeledGraphics
|
||
|
*/
|
||
|
debugInfos() {
|
||
|
console.log({ size: this.labels.size, labels: this.labels })
|
||
|
}
|
||
|
}
|
||
|
|
||
|
const labelCache = new Map()
|
||
|
|
||
|
function getTexture(label, fontInfo = FontInfo.normal) {
|
||
|
let key = label + fontInfo.fontFamily + fontInfo.fontSize
|
||
|
|
||
|
if (labelCache.has(key)) {
|
||
|
return labelCache.get(key)
|
||
|
}
|
||
|
let expandedFont = Object.assign({}, fontInfo)
|
||
|
expandedFont.fontSize *= window.devicePixelRatio
|
||
|
let text = new PIXI.Text(label, expandedFont)
|
||
|
text.updateText()
|
||
|
labelCache.set(key, text.texture)
|
||
|
return text.texture
|
||
|
}
|
||
|
|
||
|
class SpriteLabel extends PIXI.Sprite {
|
||
|
constructor(label, fontInfo) {
|
||
|
let texture = getTexture(label, fontInfo)
|
||
|
super(texture)
|
||
|
this.label = label
|
||
|
this.fontInfo = fontInfo
|
||
|
this.scale.set(0.8 / window.devicePixelRatio)
|
||
|
}
|
||
|
|
||
|
set text(label) {
|
||
|
this.label = label
|
||
|
this.texture = getTexture(label, this.fontInfo)
|
||
|
}
|
||
|
|
||
|
get text() {
|
||
|
return this.label
|
||
|
}
|
||
|
}
|
||
|
|
||
|
export class BitmapLabeledGraphics extends LabeledGraphics {
|
||
|
_createText(label, fontInfo) {
|
||
|
let texture = getTexture(label, fontInfo)
|
||
|
return new SpriteLabel(texture)
|
||
|
}
|
||
|
}
|