diff --git a/packages/@ourworldindata/components/src/TextWrap/TextWrap.test.ts b/packages/@ourworldindata/components/src/TextWrap/TextWrap.test.ts index 055a33ad69e..8d21c1d01b9 100755 --- a/packages/@ourworldindata/components/src/TextWrap/TextWrap.test.ts +++ b/packages/@ourworldindata/components/src/TextWrap/TextWrap.test.ts @@ -125,4 +125,23 @@ describe("lines()", () => { "an important line", ]) }) + + it("should properly close and re-open HTML tags that span multiple lines", () => { + // the HTML version of this string won't fit into a width of 150, but it will once the HTML tags are stripped + // - that's what the rawHtml mode is for. + const text = "a very important message here" + const wrap = new TextWrap({ + text, + maxWidth: 10, + fontSize: FONT_SIZE, + rawHtml: true, + }) + expect(wrap.lines.map((l) => l.text)).toEqual([ + "a", + "very", + "important", + "message", + "here", + ]) + }) }) diff --git a/packages/@ourworldindata/components/src/TextWrap/TextWrap.tsx b/packages/@ourworldindata/components/src/TextWrap/TextWrap.tsx index 36a900227cd..b0336f51ee7 100644 --- a/packages/@ourworldindata/components/src/TextWrap/TextWrap.tsx +++ b/packages/@ourworldindata/components/src/TextWrap/TextWrap.tsx @@ -25,8 +25,12 @@ interface WrapLine { height: number } -const HTML_OPENING_TAG_REGEX = /<(\w+)[^>]*>/ -const HTML_CLOSING_TAG_REGEX = /<\/(\w)+>/ +interface OpenHtmlTag { + tag: string // e.g. "a" for an tag, or "span" for a tag + fullTag: string // e.g. "" +} + +const HTML_OPENING_CLOSING_TAG_REGEX = /<(\/?)([A-Za-z]+)[^>]*>/g function startsWithNewline(text: string): boolean { return /^\n/.test(text) @@ -78,6 +82,50 @@ export class TextWrap { return this.props.text } + // 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. + // This is so we don't produce invalid HTML. + processHtmlTags(lines: WrapLine[]): WrapLine[] { + const currentlyOpenTags: OpenHtmlTag[] = [] + for (const line of lines) { + // Prepend any still-open tags to the start of the line + const prependOpenTags = currentlyOpenTags + .map((t) => t.fullTag) + .join("") + + const tagMatches = line.text.matchAll( + HTML_OPENING_CLOSING_TAG_REGEX + ) + for (const tag of tagMatches) { + const isOpeningTag = tag[1] !== "/" + if (isOpeningTag) { + currentlyOpenTags.push({ + tag: tag[2], + fullTag: tag[0], + }) + } else { + if ( + !currentlyOpenTags.length || + currentlyOpenTags.at(-1)?.tag !== tag[2] + ) { + throw new Error( + "TextWrap: Opening and closing HTML tags do not match" + ) + } + currentlyOpenTags.pop() + } + } + + // Append any unclosed tags to the end of the line + const appendCloseTags = [...currentlyOpenTags] + .reverse() + .map((t) => ``) + .join("") + line.text = prependOpenTags + line.text + appendCloseTags + } + return lines + } + @computed get lines(): WrapLine[] { const { text, maxWidth, fontSize, fontWeight } = this @@ -136,7 +184,9 @@ export class TextWrap { height: lineBounds.height, }) - return lines + // Process HTML to ensure that each opening tag has a matching closing tag _in each line_ + if (this.props.rawHtml) return this.processHtmlTags(lines) + else return lines } @computed get height(): number {