Skip to content

Commit

Permalink
feat(text): improved word wrap function (#1761)
Browse files Browse the repository at this point in the history
  • Loading branch information
markov00 authored Aug 1, 2022
1 parent 25d386d commit eaf0d59
Show file tree
Hide file tree
Showing 4 changed files with 263 additions and 0 deletions.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
129 changes: 129 additions & 0 deletions packages/charts/src/utils/text/wrap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

import { Font } from '../../common/text_utils';
import { monotonicHillClimb } from '../../solvers/monotonic_hill_climb';
import { TextMeasure } from '../bbox/canvas_text_bbox_calculator';

const ELLIPSIS = '…';

/** @internal */
export function wrapText(
text: string,
font: Font,
fontSize: number,
maxLineWidth: number,
maxLines: number,
measure: TextMeasure,
): string[] {
if (maxLines <= 0) {
return [];
}
// TODO add locale
const segmenter = textSegmenter([]);
// remove new lines and multi-spaces.
const cleanedText = text.replace(/\n/g, ' ').replace(/ +(?= )/g, '');

const segments = Array.from(segmenter(cleanedText)).map((d) => ({
...d,
width: measure(d.segment, font, fontSize).width,
}));

const ellipsisWidth = measure(ELLIPSIS, font, fontSize).width;
const lines: string[] = [];
let currentLineWidth = 0;
for (const segment of segments) {
// the word is longer then the available space and is not a space
if (currentLineWidth + segment.width > maxLineWidth && segment.segment.trimStart().length > 0) {
// TODO call breakLongTextIntoLines with the remaining lines
const multilineText = breakLongTextIntoLines(segment.segment, font, fontSize, maxLineWidth, Infinity, measure);
// required to break the loop when a word can't fit into the next line. In this case, we don't want to skip that
// long word, but we want to interrupt the loop
if (multilineText.length === 0) {
break;
}
lines.push(...multilineText);
currentLineWidth =
multilineText.length > 0 ? measure(multilineText[multilineText.length - 1], font, fontSize).width : 0;
} else {
const lineIndex = lines.length > 0 ? lines.length - 1 : 0;
lines[lineIndex] = (lines[lineIndex] ?? '') + segment.segment;
currentLineWidth += segment.width;
}
}
if (lines.length > maxLines) {
const lastLineMaxLineWidth = maxLineWidth - ellipsisWidth;
const lastLine = clipTextToWidth(lines[maxLines - 1], font, fontSize, lastLineMaxLineWidth, measure);
if (lastLine.length > 0) {
lines.splice(maxLines - 1, Infinity, `${lastLine}${ELLIPSIS}`);
} else {
if (lastLineMaxLineWidth > 0) {
lines.splice(maxLines - 1, Infinity, ELLIPSIS);
} else {
lines.splice(maxLines, Infinity);
}
}
}
return lines;
}

function textSegmenter(locale: string[]): (text: string) => { segment: string; index: number; isWordLike?: boolean }[] {
if ('Segmenter' in Intl) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
const fn = new Intl.Segmenter(locale, { granularity: 'word' });
return (text: string) => Array.from(fn.segment(text));
} else {
return (text: string) => {
return text
.split(' ')
.reduce<{ segment: string; index: number; isWordLike?: boolean }[]>((acc, segment, index, array) => {
const currentSegment = { segment, index: index === 0 ? 0 : acc[acc.length - 1].index + 1, isWordLike: true };
acc.push(currentSegment);
// adding space to simulate the same behaviour of the segmenter in firefox
if (index < array.length - 1) {
acc.push({ segment: ' ', index: currentSegment.index + segment.length, isWordLike: false });
}
return acc;
}, []);
};
}
}

function breakLongTextIntoLines(
text: string,
font: Font,
fontSize: number,
lineWidth: number,
maxLines: number,
measure: TextMeasure,
) {
const lines: string[] = [];
let remainingText = text;
while (maxLines > lines.length && remainingText.length > 0) {
const lineClippedText = clipTextToWidth(remainingText, font, fontSize, lineWidth, measure);
if (lineClippedText.length === 0) {
break;
} else {
lines.push(lineClippedText.trimStart());
remainingText = remainingText.slice(lineClippedText.length, Infinity);
}
}
return lines;
}

function clipTextToWidth(text: string, font: Font, fontSize: number, width: number, measure: TextMeasure): string {
const maxCharsInLine = monotonicHillClimb(
(chars) => measure(text.slice(0, chars), font, fontSize).width,
text.length,
width,
(n: number) => Math.floor(n),
0,
);
return text.slice(0, maxCharsInLine || 0);
}
121 changes: 121 additions & 0 deletions storybook/stories/utils/text/1_wrap.story.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

import React, { useEffect, useRef, useState } from 'react';

import { Font, cssFontShorthand } from '@elastic/charts/src/common/text_utils';
import { withContext } from '@elastic/charts/src/renderers/canvas';
import { withTextMeasure } from '@elastic/charts/src/utils/bbox/canvas_text_bbox_calculator';
import { wrapText } from '@elastic/charts/src/utils/text/wrap';

const fontSize = 24;
const font: Font = {
fontStyle: 'normal',
fontFamily: 'sans-serif',
fontVariant: 'normal',
fontWeight: 500,
textColor: 'red',
};
const fontStyle = cssFontShorthand(font, fontSize);
const defaultText =
'Bacon ipsum dolor amet mongoloadgendecoblue58d844d55c-9c24dtip flank kielbasa. Pork strip steak jowl chuck filet mignon, burgdoggen kevin tail.';

export const Example = () => {
const [maxLineWidth, setMaxLineWidth] = useState(250);
const [maxLines, setMaxLines] = useState(3);
const [text, setText] = useState(defaultText);
const canvasRef = useRef<HTMLCanvasElement>(null);

useEffect(() => {
const canvas = canvasRef.current!;
const ctx = canvas.getContext('2d')!;

withContext(ctx, () => {
ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
ctx.fillStyle = 'white';
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);

ctx.font = fontStyle;
ctx.textBaseline = 'top';
ctx.strokeStyle = 'black';
ctx.fillStyle = 'black';
ctx.strokeRect(0, 0, maxLineWidth, fontSize * maxLines);
withTextMeasure((measure) => {
const lines = wrapText(text, font, fontSize, maxLineWidth, maxLines, measure);
lines.forEach((line, i) => {
ctx.fillText(line, 0, i * fontSize);
});
});
});
}, [text, maxLineWidth, maxLines]);
const width = 500;
const height = 500;
return (
<div className="echChart">
<div className="echChartStatus" data-ech-render-complete={true} />
<div>
<label style={{ display: 'inline-block', width: 200 }}> Max Line Width [{maxLineWidth}px]</label>
<input
type="range"
min={0}
max={width}
value={maxLineWidth}
className="slider"
id="maxLineWidth"
onInput={(e) => setMaxLineWidth(Number(e.currentTarget.value))}
/>
<br />
<label style={{ display: 'inline-block', width: 200 }}> Max Lines [{maxLines}]</label>
<input
type="range"
min="0"
max="10"
value={maxLines}
className="slider"
id="maxLines"
onInput={(e) => setMaxLines(Number(e.currentTarget.value))}
/>
<div
style={{
margin: '20px 0',
}}
>
<p>HTML Text (editable)</p>

<textarea
style={{
padding: 0,
margin: 0,
width: maxLineWidth,
height: maxLines * fontSize,
fontSize,
overflow: 'hidden',
fontFamily: font.fontFamily,
fontStyle: font.fontStyle,
fontVariant: font.fontVariant,
lineHeight: `${fontSize}px`,
resize: 'none',
textOverflow: 'ellipsis',
}}
value={text}
onInput={(e) => setText(e.currentTarget.value)}
/>
</div>

<p>Canvas Text</p>
<canvas
ref={canvasRef}
width={width * window.devicePixelRatio}
height={height * window.devicePixelRatio}
style={{ width, height }}
/>
</div>
</div>
);
};
13 changes: 13 additions & 0 deletions storybook/stories/utils/text/text.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

export { Example as wrap } from './1_wrap.story';

export default {
title: 'Utils/Text',
};

0 comments on commit eaf0d59

Please sign in to comment.