Created
August 16, 2018 10:29
-
-
Save zaaack/7d5beeadc3a06c31f52c569f6ca4e1af to your computer and use it in GitHub Desktop.
SVG auto-wrapped text component for React
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import 'core-js/fn/set' | |
import 'core-js/fn/map' | |
import React from 'react' | |
import svgTextSize from 'svg-text-size'; | |
const rightSymbols = new Set( | |
'“‘[「【﹝〔<‹«{『((<《'.split('') | |
) | |
const leftSymbols = new Set( | |
',,,!!??”\]」】》>’»﹞〕〗〉}))』'.split('') | |
) | |
const toggleSymbols = new Map([ | |
['\'', 'right'], | |
['"', 'right'], | |
]) | |
// Takes a string, and a width (and svg attrs, if they apply), and returns | |
// an array of lines, representing the break points in the string. | |
const textWrap = (text, width, attrs, doc = document, { | |
_leftSymbols = leftSymbols, | |
_rightSymbols = rightSymbols, | |
_toggleSymbols = toggleSymbols, | |
}) => { | |
let words = [] | |
let toggleMap = new Map() | |
for (let i = 0; i < text.length; i++) { | |
const char = text[i]; | |
if (_rightSymbols.has(char)) { | |
words.push(char + text[i + 1]) | |
i += 1 | |
} else if (_leftSymbols.has(char)) { | |
words[words.length - 1] += char | |
} else if (_toggleSymbols.has(char)) { | |
let isRightStart = _toggleSymbols.get(char) === 'right' | |
if (!toggleMap.has(char)) { | |
toggleMap.set(char, isRightStart ? 'right' : 'left') | |
} | |
let stickyDirection = toggleMap.get(char) | |
if (stickyDirection === 'right') { | |
words.push(char + text[i + 1]) | |
i += 1 | |
} else { | |
words[words.length - 1] += char | |
} | |
toggleMap.set(char, stickyDirection === 'left' ? 'right' : 'left') | |
} else if (/^\w+$/.test(char)) { | |
let last = words[words.length - 1] | |
if (/^\w+$/.test(last)) { | |
words[words.length - 1] += char | |
} else { | |
words.push(char) | |
} | |
} else { | |
words.push(char) | |
} | |
} | |
let lines = []; | |
let currentLine = []; | |
words.forEach(word => { | |
const newLine = [...currentLine, word]; | |
const size = svgTextSize(newLine.join(''), attrs, doc); | |
if (size.width > width) { | |
lines.push(currentLine.join('')); | |
currentLine = [word]; | |
} else { | |
currentLine.push(word); | |
} | |
}); | |
lines.push(currentLine.join('')); | |
if (lines[0] === '') { lines.shift(); } | |
return lines; | |
}; | |
function getReactAttr(attrs) { | |
let newAttrs = {} | |
for (let key in attrs) { | |
let val = attrs[key] | |
key = key.replace(/([A-Z])/g, s => '-' + s.toLowerCase()) | |
switch (key) { | |
case 'font-size': | |
case 'line-spacing': | |
case 'letter-spacing': | |
if (typeof val === 'number') { | |
val += 'px' | |
} | |
break; | |
default: | |
break; | |
} | |
newAttrs[key] = val | |
} | |
return newAttrs | |
} | |
/** | |
``` | |
props: { | |
spans: [ | |
"some text", | |
{ tag: 'tspan', text: 'some text', props: { } } | |
], | |
width: 100, | |
x: 0, | |
y: 0, | |
} | |
``` | |
*/ | |
export default class SVGAutoText extends React.PureComponent { | |
getAttrProps() { | |
const { | |
spans, width, x, y, | |
leftSymbols, rightSymbols, toggleSymbols, | |
...props } = this.props | |
return props | |
} | |
getWrappedSpans() { | |
let spanSlices = [] | |
let startIdx = 0 | |
let fullText = '' | |
let spans = this.props.spans.map( | |
span => { | |
if (!span.tag) { | |
return { | |
tag: 'tspan', | |
text: span ? span + '' : '', | |
props: null, | |
} | |
} | |
span.text += '' | |
return span | |
} | |
) | |
for (const span of spans) { | |
let end = startIdx + span.text.length | |
spanSlices.push({ | |
...span, | |
start: startIdx, | |
end, | |
}) | |
startIdx = end | |
fullText += span.text | |
} | |
let globalProps = this.getAttrProps() | |
let lines = textWrap(fullText, this.props.width, getReactAttr(globalProps), this.props) | |
let tspans = [] | |
let lineIdx = 0 | |
let lineLen = 0 | |
let sliceIdx = 0 | |
let sliceLen = 0 | |
let lastLine = -1 | |
let { x: offsetX = 0, y: offsetY = 0 } = this.props | |
let lineHeight = parseInt(globalProps['lineSpacing'] || globalProps['lineHeight'], 10) | |
let fontSize = parseInt(globalProps['fontSize'], 10) || 16 | |
function makeTspan(text, props, lineIdx) { | |
let x = lastLine === lineIdx ? void 0 : offsetX | |
lastLine = lineIdx | |
return ( | |
<tspan | |
key={tspans.length + text} | |
{...props} | |
x={x} | |
y={lineIdx * (lineHeight ? lineHeight : fontSize * 1.4) + offsetY} | |
> | |
{text} | |
</tspan> | |
) | |
} | |
for (const line of lines) { | |
lineLen += line.length | |
while (sliceLen < lineLen && sliceIdx < spanSlices.length) { | |
let slice = spanSlices[sliceIdx] | |
tspans.push(makeTspan(slice.text, slice.props, lineIdx)) | |
sliceIdx++ | |
sliceLen += slice.text.length | |
} | |
if (sliceLen > lineLen) { // break slice | |
let lastSlice = spanSlices[sliceIdx - 1] | |
let text1 = lastSlice.text.slice(0, lastSlice.text.length - (sliceLen - lineLen)) | |
let text2 = lastSlice.text.slice(text1.length) | |
tspans.pop() | |
tspans.push(makeTspan(text1, lastSlice.props, lineIdx)) | |
tspans.push(makeTspan(text2, lastSlice.props, lineIdx + 1)) | |
} | |
lineIdx++ | |
} | |
return tspans | |
} | |
render() { | |
return ( | |
<text {...this.getAttrProps()}> | |
{this.getWrappedSpans()} | |
</text> | |
) | |
} | |
} |
Author
zaaack
commented
Aug 16, 2018
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment