From 91b6115414262db782496cb05d19bb541d7d6ebc Mon Sep 17 00:00:00 2001 From: Marcel Gerber Date: Wed, 15 Nov 2023 16:29:57 +0000 Subject: [PATCH 1/3] docs: add some comments to TextWrap --- .../@ourworldindata/components/src/TextWrap/TextWrap.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/@ourworldindata/components/src/TextWrap/TextWrap.tsx b/packages/@ourworldindata/components/src/TextWrap/TextWrap.tsx index e1007f7acab..36a900227cd 100644 --- a/packages/@ourworldindata/components/src/TextWrap/TextWrap.tsx +++ b/packages/@ourworldindata/components/src/TextWrap/TextWrap.tsx @@ -25,6 +25,9 @@ interface WrapLine { height: number } +const HTML_OPENING_TAG_REGEX = /<(\w+)[^>]*>/ +const HTML_CLOSING_TAG_REGEX = /<\/(\w)+>/ + function startsWithNewline(text: string): boolean { return /^\n/.test(text) } @@ -106,12 +109,14 @@ export class TextWrap { startsWithNewline(word) || (nextBounds.width + 10 > maxWidth && line.length >= 1) ) { - const wordWithoutNewline = word.replace(/^\n/, "") + // Introduce a newline _before_ this word lines.push({ text: line.join(" "), 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] lineBounds = Bounds.forText(wordWithoutNewline, { fontSize, @@ -122,6 +127,8 @@ export class TextWrap { lineBounds = nextBounds } }) + + // Push the last line if (line.length > 0) lines.push({ text: line.join(" "), From 0352b27410a140e3c1e89fb06ad3317472335120 Mon Sep 17 00:00:00 2001 From: Marcel Gerber Date: Wed, 15 Nov 2023 17:20:19 +0000 Subject: [PATCH 2/3] enhance: TextWrap opens and closes every HTML tag --- .../components/src/TextWrap/TextWrap.test.ts | 19 +++++++ .../components/src/TextWrap/TextWrap.tsx | 56 ++++++++++++++++++- 2 files changed, 72 insertions(+), 3 deletions(-) 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 { From 1c582dacd9198d75b8dce1a5b6f90702a382e1b5 Mon Sep 17 00:00:00 2001 From: Marcel Gerber Date: Sat, 18 Nov 2023 09:07:09 +0000 Subject: [PATCH 3/3] enhance: rewrite regex for perf --- packages/@ourworldindata/components/src/TextWrap/TextWrap.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@ourworldindata/components/src/TextWrap/TextWrap.tsx b/packages/@ourworldindata/components/src/TextWrap/TextWrap.tsx index b0336f51ee7..7660a504696 100644 --- a/packages/@ourworldindata/components/src/TextWrap/TextWrap.tsx +++ b/packages/@ourworldindata/components/src/TextWrap/TextWrap.tsx @@ -30,7 +30,7 @@ interface OpenHtmlTag { fullTag: string // e.g. "" } -const HTML_OPENING_CLOSING_TAG_REGEX = /<(\/?)([A-Za-z]+)[^>]*>/g +const HTML_OPENING_CLOSING_TAG_REGEX = /<(\/?)([A-Za-z]+)( [^<>]*)?>/g function startsWithNewline(text: string): boolean { return /^\n/.test(text)