Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

enhance: TextWrap opens and closes every HTML tag #2927

Merged
merged 3 commits into from
Nov 18, 2023
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions packages/@ourworldindata/components/src/TextWrap/TextWrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,4 +125,23 @@ describe("lines()", () => {
"an <strong>important</strong> <a href='https://youtu.be/dQw4w9WgXcQ'>line</a>",
])
})

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 <strong><i>very important</i> message</strong> here"
const wrap = new TextWrap({
text,
maxWidth: 10,
fontSize: FONT_SIZE,
rawHtml: true,
})
expect(wrap.lines.map((l) => l.text)).toEqual([
"a",
"<strong><i>very</i></strong>",
"<strong><i>important</i></strong>",
"<strong>message</strong>",
"here",
])
})
})
61 changes: 59 additions & 2 deletions packages/@ourworldindata/components/src/TextWrap/TextWrap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@
height: number
}

interface OpenHtmlTag {
tag: string // e.g. "a" for an <a> tag, or "span" for a <span> tag
fullTag: string // e.g. "<a href='https://ourworldindata.org'>"
}

const HTML_OPENING_CLOSING_TAG_REGEX = /<(\/?)([A-Za-z]+)[^>]*>/g

function startsWithNewline(text: string): boolean {
return /^\n/.test(text)
}
Expand Down Expand Up @@ -75,6 +82,50 @@
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
)
Fixed Show fixed Hide fixed
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) => `</${t.tag}>`)
.join("")
line.text = prependOpenTags + line.text + appendCloseTags
}
return lines
}

@computed get lines(): WrapLine[] {
const { text, maxWidth, fontSize, fontWeight } = this

Expand Down Expand Up @@ -106,12 +157,14 @@
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,
Expand All @@ -122,14 +175,18 @@
lineBounds = nextBounds
}
})

// Push the last line
if (line.length > 0)
lines.push({
text: line.join(" "),
width: lineBounds.width,
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 {
Expand Down
Loading