diff --git a/packages/@ourworldindata/components/src/TextWrap/TextWrap.tsx b/packages/@ourworldindata/components/src/TextWrap/TextWrap.tsx index d32897f7c1e..4f0ece86fa4 100644 --- a/packages/@ourworldindata/components/src/TextWrap/TextWrap.tsx +++ b/packages/@ourworldindata/components/src/TextWrap/TextWrap.tsx @@ -1,12 +1,7 @@ -import { - isEmpty, - max, - stripHTML, - Bounds, - FontFamily, -} from "@ourworldindata/utils" +import { max, stripHTML, Bounds, FontFamily } from "@ourworldindata/utils" import { computed } from "mobx" import React from "react" +import { Fragment, joinFragments, splitIntoFragments } from "./TextWrapUtils" declare type FontSize = number @@ -16,6 +11,7 @@ interface TextWrapProps { lineHeight?: number fontSize: FontSize fontWeight?: number + separators?: string[] rawHtml?: boolean } @@ -81,6 +77,9 @@ export class TextWrap { @computed get text(): string { return this.props.text } + @computed get separators(): string[] { + return this.props.separators ?? [" "] + } // We need to take care that HTML tags are not split across lines. // Instead, we want every line to have opening and closing tags for all tags that appear. @@ -127,26 +126,27 @@ export class TextWrap { } @computed get lines(): WrapLine[] { - const { text, maxWidth, fontSize, fontWeight } = this + const { text, separators, maxWidth, fontSize, fontWeight } = this - const words = isEmpty(text) - ? [] - : // Prepend spaces so that the string is also split before newline characters - // See startsWithNewline - text.replace(/\n/g, " \n").split(" ") + // Prepend spaces so that the string is also split before newline characters + // See startsWithNewline + const fragments = splitIntoFragments( + text.replace(/\n/g, " \n"), + separators + ) const lines: WrapLine[] = [] - let line: string[] = [] + let line: Fragment[] = [] let lineBounds = Bounds.empty() - words.forEach((word) => { - const nextLine = line.concat([word]) + fragments.forEach((fragment) => { + const nextLine = line.concat([fragment]) // Strip HTML if a raw string is passed const text = this.props.rawHtml - ? stripHTML(nextLine.join(" ")) - : nextLine.join(" ") + ? stripHTML(joinFragments(nextLine)) + : joinFragments(nextLine) const nextBounds = Bounds.forText(text, { fontSize, @@ -154,18 +154,23 @@ export class TextWrap { }) if ( - startsWithNewline(word) || + startsWithNewline(fragment.text) || (nextBounds.width + 10 > maxWidth && line.length >= 1) ) { // Introduce a newline _before_ this word lines.push({ - text: line.join(" "), + text: joinFragments(line), width: lineBounds.width, height: lineBounds.height, }) // ... and start a new line with this word (with a potential leading newline stripped) - const wordWithoutNewline = word.replace(/^\n/, "") - line = [wordWithoutNewline] + const wordWithoutNewline = fragment.text.replace(/^\n/, "") + line = [ + { + text: wordWithoutNewline, + separator: fragment.separator, + }, + ] lineBounds = Bounds.forText(wordWithoutNewline, { fontSize, fontWeight, @@ -179,7 +184,7 @@ export class TextWrap { // Push the last line if (line.length > 0) lines.push({ - text: line.join(" "), + text: joinFragments(line), width: lineBounds.width, height: lineBounds.height, }) diff --git a/packages/@ourworldindata/components/src/TextWrap/TextWrapUtils.test.ts b/packages/@ourworldindata/components/src/TextWrap/TextWrapUtils.test.ts new file mode 100644 index 00000000000..6cae2e6092e --- /dev/null +++ b/packages/@ourworldindata/components/src/TextWrap/TextWrapUtils.test.ts @@ -0,0 +1,43 @@ +#! /usr/bin/env jest + +import { joinFragments, splitIntoFragments } from "./TextWrapUtils" + +it("splits text correctly into fragments", () => { + expect(splitIntoFragments("")).toEqual([]) + expect(splitIntoFragments("word")).toEqual([ + { text: "word", separator: "" }, + ]) + expect(splitIntoFragments("an example line")).toEqual([ + { text: "an", separator: " " }, + { text: "example", separator: " " }, + { text: "line", separator: "" }, + ]) + expect(splitIntoFragments("high-income countries")).toEqual([ + { text: "high-income", separator: " " }, + { text: "countries", separator: "" }, + ]) + expect(splitIntoFragments("high-income countries", [" ", "-"])).toEqual([ + { text: "high", separator: "-" }, + { text: "income", separator: " " }, + { text: "countries", separator: "" }, + ]) +}) + +it("splits and joins text correctly into fragments", () => { + const examples = [ + "", + "word", + "an example line", + "an example spaced out text", + "an example-with-hyphens", + "an example - with - a - differennt - kind-of - hyphen", + "hyphen at the end -", + "a mixed-bag - ok", + ] + for (const text of examples) { + expect(joinFragments(splitIntoFragments(text))).toEqual(text) + expect(joinFragments(splitIntoFragments(text, [" ", "-"]))).toEqual( + text + ) + } +}) diff --git a/packages/@ourworldindata/components/src/TextWrap/TextWrapUtils.ts b/packages/@ourworldindata/components/src/TextWrap/TextWrapUtils.ts new file mode 100644 index 00000000000..c9b6bbe94be --- /dev/null +++ b/packages/@ourworldindata/components/src/TextWrap/TextWrapUtils.ts @@ -0,0 +1,32 @@ +import { isEmpty } from "@ourworldindata/utils" + +export type Fragment = { + text: string + separator: string +} + +export function splitIntoFragments( + text: string, + separators = [" "] +): Fragment[] { + if (isEmpty(text)) return [] + const fragments: Fragment[] = [] + let currText = "" + for (const char of text) { + if (separators.includes(char)) { + fragments.push({ text: currText, separator: char }) + currText = "" + } else { + currText += char + } + } + fragments.push({ text: currText, separator: "" }) + return fragments +} + +export function joinFragments(fragments: Fragment[]): string { + return fragments + .map(({ text, separator }) => text + separator) + .join("") + .trim() +} diff --git a/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx b/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx index f4b50cfd839..b359d5472b6 100644 --- a/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx +++ b/packages/@ourworldindata/grapher/src/slopeCharts/SlopeChart.tsx @@ -925,6 +925,7 @@ class LabelledSlopes ...valueLabelProps, maxWidth, fontWeight: 700, + separators: [" ", "-"], } const leftEntityLabel = new TextWrap({ text,