-
Notifications
You must be signed in to change notification settings - Fork 274
feat: optimize functions for getting text dimension #199
Changes from 3 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,61 @@ | ||
import { TextStyle, Dimension } from './types'; | ||
import getBBoxCeil from './svg/getBBoxCeil'; | ||
import { hiddenSvgFactory, textFactory } from './svg/factories'; | ||
import updateTextNode from './svg/updateTextNode'; | ||
|
||
/** | ||
* get dimensions of multiple texts with same style | ||
* @param input | ||
* @param defaultDimension | ||
*/ | ||
export default function getMultipleTextDimensions( | ||
input: { | ||
className?: string; | ||
container?: HTMLElement; | ||
style?: TextStyle; | ||
texts: string[]; | ||
}, | ||
defaultDimension?: Dimension, | ||
): Dimension[] { | ||
const { texts, className, style, container } = input; | ||
|
||
const cache = new Map<string, Dimension>(); | ||
let textNode: SVGTextElement | undefined; | ||
let svgNode: SVGSVGElement | undefined; | ||
|
||
const dimensions = texts.map(text => { | ||
// Empty string | ||
if (text.length === 0) { | ||
return { height: 0, width: 0 }; | ||
} | ||
// Check if this string has been computed already | ||
if (cache.has(text)) { | ||
return cache.get(text) as Dimension; | ||
} | ||
|
||
// Lazy creation of text and svg nodes | ||
if (!textNode) { | ||
svgNode = hiddenSvgFactory.createInContainer(container); | ||
textNode = textFactory.createInContainer(svgNode); | ||
} | ||
|
||
// Update text and get dimension | ||
updateTextNode(textNode, { className, style, text }); | ||
const dimension = getBBoxCeil(textNode, defaultDimension); | ||
// Store result to cache | ||
cache.set(text, dimension); | ||
|
||
return dimension; | ||
}); | ||
|
||
// Remove svg node, if any | ||
if (svgNode && textNode) { | ||
setTimeout(() => { | ||
kristw marked this conversation as resolved.
Show resolved
Hide resolved
|
||
textFactory.removeFromContainer(svgNode); | ||
hiddenSvgFactory.removeFromContainer(container); | ||
// eslint-disable-next-line no-magic-numbers | ||
}, 500); | ||
} | ||
|
||
return dimensions; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,41 @@ | ||
export default class LazyFactory<T extends HTMLElement | SVGElement> { | ||
private cache = new Map< | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is cache the right name for this? Seems like it's storing each node for its entire lifecycle, and not really being used as a cache There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't have strong opinion here. What would you suggest it be called? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It is hashed by containers but not storing the containers, so probably should not be called |
||
HTMLElement | SVGElement, | ||
{ | ||
counter: number; | ||
node: T; | ||
} | ||
>(); | ||
|
||
private factoryFn: () => T; | ||
|
||
constructor(factoryFn: () => T) { | ||
this.factoryFn = factoryFn; | ||
} | ||
|
||
createInContainer(container: HTMLElement | SVGElement = document.body) { | ||
if (this.cache.has(container)) { | ||
const entry = this.cache.get(container)!; | ||
entry.counter += 1; | ||
|
||
return entry.node; | ||
} | ||
|
||
const node = this.factoryFn(); | ||
container.appendChild(node); | ||
this.cache.set(container, { counter: 1, node }); | ||
|
||
return node; | ||
} | ||
|
||
removeFromContainer(container: HTMLElement | SVGElement = document.body) { | ||
if (this.cache.has(container)) { | ||
const entry = this.cache.get(container)!; | ||
entry.counter -= 1; | ||
if (entry.counter === 0) { | ||
container.removeChild(entry.node); | ||
this.cache.delete(container); | ||
} | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
// eslint-disable-next-line import/prefer-default-export | ||
export const SVG_NS = 'http://www.w3.org/2000/svg'; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
import { SVG_NS } from './constants'; | ||
|
||
export default function createHiddenSvgNode() { | ||
const svgNode = document.createElementNS(SVG_NS, 'svg'); | ||
svgNode.style.position = 'absolute'; // so it won't disrupt page layout | ||
svgNode.style.opacity = '0'; // and not visible | ||
svgNode.style.pointerEvents = 'none'; // and not capturing mouse events | ||
|
||
return svgNode; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
import { SVG_NS } from './constants'; | ||
|
||
export default function createTextNode() { | ||
return document.createElementNS(SVG_NS, 'text'); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
import LazyFactory from './LazyFactory'; | ||
import createHiddenSvgNode from './createHiddenSvgNode'; | ||
import createTextNode from './createTextNode'; | ||
|
||
export const hiddenSvgFactory = new LazyFactory(createHiddenSvgNode); | ||
export const textFactory = new LazyFactory(createTextNode); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
import { Dimension } from '../types'; | ||
|
||
const DEFAULT_DIMENSION = { height: 20, width: 100 }; | ||
|
||
export default function getBBoxCeil( | ||
kristw marked this conversation as resolved.
Show resolved
Hide resolved
|
||
node: SVGGraphicsElement, | ||
defaultDimension: Dimension = DEFAULT_DIMENSION, | ||
) { | ||
const { width, height } = node.getBBox ? node.getBBox() : defaultDimension; | ||
|
||
return { | ||
height: Math.ceil(height), | ||
width: Math.ceil(width), | ||
}; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,47 @@ | ||
import { TextStyle } from '../types'; | ||
|
||
const STYLE_FIELDS: (keyof TextStyle)[] = [ | ||
'font', | ||
'fontWeight', | ||
'fontStyle', | ||
'fontSize', | ||
'fontFamily', | ||
'letterSpacing', | ||
]; | ||
|
||
export default function updateTextNode( | ||
node: SVGTextElement, | ||
{ | ||
className, | ||
style = {}, | ||
text, | ||
}: { | ||
className?: string; | ||
style?: TextStyle; | ||
text?: string; | ||
} = {}, | ||
) { | ||
const textNode = node; | ||
|
||
if (textNode.textContent !== text) { | ||
textNode.textContent = typeof text === 'undefined' ? null : text; | ||
} | ||
if (textNode.getAttribute('class') !== className) { | ||
textNode.setAttribute('class', className || ''); | ||
} | ||
|
||
// clear style | ||
STYLE_FIELDS.forEach((field: keyof TextStyle) => { | ||
textNode.style[field] = null; | ||
}); | ||
|
||
// apply new style | ||
// Note that the font field will auto-populate other font fields when applicable. | ||
STYLE_FIELDS.filter( | ||
(field: keyof TextStyle) => typeof style[field] !== 'undefined' && style[field] !== null, | ||
).forEach((field: keyof TextStyle) => { | ||
textNode.style[field] = `${style[field]}`; | ||
}); | ||
|
||
return textNode; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
instead of this special case, why not initialize the cache with:
then we don't need to check both cases every time (and i'd imagine the O(1) cache lookup would be about the same speed as the string length compare)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I generally agree with this, but the W3C SVGGraphicsElement spec has a method named getBBox (that we use on line 9), so this instance may be an acceptable exception