171 lines
3.6 KiB
JavaScript
171 lines
3.6 KiB
JavaScript
// Thanks, https://stackoverflow.com/a/9851769/1180785
|
|
const firefox = (typeof window.InstallTrigger !== 'undefined');
|
|
|
|
function merge(state, newState) {
|
|
for(const k in state) {
|
|
if(Object.prototype.hasOwnProperty.call(state, k)) {
|
|
if(newState[k] !== null && typeof newState[k] !== 'undefined') {
|
|
state[k] = newState[k];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function populateSvgTextLine(svg, node, formattedLine) {
|
|
if(!Array.isArray(formattedLine)) {
|
|
throw new Error('Invalid formatted text line: ' + formattedLine);
|
|
}
|
|
formattedLine.forEach(({text, attrs}) => {
|
|
if(attrs) {
|
|
node.add(svg.el('tspan').attrs(attrs).add(text));
|
|
} else {
|
|
node.add(text);
|
|
}
|
|
});
|
|
}
|
|
|
|
const EMPTY = [];
|
|
|
|
export class SVGTextBlock {
|
|
constructor(container, svg, initialState = {}) {
|
|
this.container = container;
|
|
this.svg = svg;
|
|
this.state = {
|
|
attrs: {},
|
|
formatted: EMPTY,
|
|
x: 0,
|
|
y: 0,
|
|
};
|
|
this.lines = [];
|
|
this.set(initialState);
|
|
}
|
|
|
|
_rebuildLines(count) {
|
|
if(count > this.lines.length) {
|
|
while(this.lines.length < count) {
|
|
this.lines.push({
|
|
latest: '',
|
|
node: this.svg.el('text')
|
|
.attr('x', this.state.x)
|
|
.attrs(this.state.attrs)
|
|
.attach(this.container),
|
|
});
|
|
}
|
|
} else {
|
|
while(this.lines.length > count) {
|
|
this.lines.pop().node.detach();
|
|
}
|
|
}
|
|
}
|
|
|
|
_reset() {
|
|
this._rebuildLines(0);
|
|
}
|
|
|
|
_renderText() {
|
|
const {formatted} = this.state;
|
|
|
|
if(!formatted || !formatted.length) {
|
|
this._reset();
|
|
return;
|
|
}
|
|
if(!Array.isArray(formatted)) {
|
|
throw new Error('Invalid formatted text: ' + formatted);
|
|
}
|
|
|
|
this._rebuildLines(formatted.length);
|
|
|
|
this.lines.forEach((ln, i) => {
|
|
const id = JSON.stringify(formatted[i]);
|
|
if(id !== ln.latest) {
|
|
ln.node.empty();
|
|
populateSvgTextLine(this.svg, ln.node, formatted[i]);
|
|
ln.latest = id;
|
|
}
|
|
});
|
|
}
|
|
|
|
_updateX() {
|
|
this.lines.forEach(({node}) => {
|
|
node.attr('x', this.state.x);
|
|
});
|
|
}
|
|
|
|
_updateY() {
|
|
const sizer = this.svg.textSizer;
|
|
let curY = this.state.y;
|
|
for(let i = 0; i < this.lines.length; ++ i) {
|
|
const line = [this.state.formatted[i]];
|
|
const baseline = sizer.baseline(this.state.attrs, line);
|
|
this.lines[i].node.attr('y', curY + baseline);
|
|
curY += sizer.measureHeight(this.state.attrs, line);
|
|
}
|
|
}
|
|
|
|
set(newState) {
|
|
const oldState = Object.assign({}, this.state);
|
|
merge(this.state, newState);
|
|
|
|
if(this.state.attrs !== oldState.attrs) {
|
|
this._reset();
|
|
oldState.formatted = EMPTY;
|
|
}
|
|
|
|
const oldLines = this.lines.length;
|
|
|
|
if(this.state.formatted !== oldState.formatted) {
|
|
this._renderText();
|
|
}
|
|
|
|
if(this.state.x !== oldState.x) {
|
|
this._updateX();
|
|
}
|
|
|
|
if(this.state.y !== oldState.y || this.lines.length !== oldLines) {
|
|
this._updateY();
|
|
}
|
|
}
|
|
}
|
|
|
|
export class TextSizer {
|
|
constructor(svg) {
|
|
this.svg = svg;
|
|
this.testers = this.svg.el('g').attrs({
|
|
// Firefox fails to measure non-displayed text
|
|
'display': firefox ? 'block' : 'none',
|
|
'visibility': 'hidden',
|
|
});
|
|
this.container = svg.body;
|
|
}
|
|
|
|
baseline({attrs}) {
|
|
return Number(attrs['font-size']);
|
|
}
|
|
|
|
measureHeight({attrs, formatted}) {
|
|
const size = this.baseline({attrs, formatted});
|
|
const lineHeight = size * (Number(attrs['line-height']) || 1);
|
|
return formatted.length * lineHeight;
|
|
}
|
|
|
|
prepMeasurement(attrs, formatted) {
|
|
const node = this.svg.el('text')
|
|
.attrs(attrs)
|
|
.attach(this.testers);
|
|
populateSvgTextLine(this.svg, node, formatted);
|
|
return node;
|
|
}
|
|
|
|
prepComplete() {
|
|
this.container.add(this.testers);
|
|
}
|
|
|
|
performMeasurement(node) {
|
|
return node.element.getComputedTextLength();
|
|
}
|
|
|
|
teardown() {
|
|
this.container.del(this.testers.empty());
|
|
}
|
|
}
|