textfields: fix RTL layout for SVG exports (#3680)

Followup to https://github.com/tldraw/tldraw/pull/3188 (although this
problem was there before that PR)

This does more work for RTL rendering in SVG context, especially since
we position each span one-by-one.

I had to do a bit of esoteric spelunking and it turns out
[`unicode-bidi:
plaintext`](https://developer.mozilla.org/en-US/docs/Web/CSS/unicode-bidi)
solves our issue even though it isn't really recommend to be used by web
developers. Fun times 🙃

Before:
<img width="369" alt="Screenshot 2024-05-02 at 11 45 44"
src="https://github.com/tldraw/tldraw/assets/469604/df55e03a-4760-4b8f-adad-ed1a8c13ad51">


After:
<img width="365" alt="Screenshot 2024-05-02 at 11 54 48"
src="https://github.com/tldraw/tldraw/assets/469604/3339bbf4-041a-4fdf-8b6e-6fa19dfb0a9e">




### Change Type

<!--  Please select a 'Scope' label ️ -->

- [x] `sdk` — Changes the tldraw SDK
- [ ] `dotcom` — Changes the tldraw.com web app
- [ ] `docs` — Changes to the documentation, examples, or templates.
- [ ] `vs code` — Changes to the vscode plugin
- [ ] `internal` — Does not affect user-facing stuff

<!--  Please select a 'Type' label ️ -->

- [x] `bugfix` — Bug fix
- [ ] `feature` — New feature
- [ ] `improvement` — Improving existing features
- [ ] `chore` — Updating dependencies, other boring stuff
- [ ] `galaxy brain` — Architectural changes
- [ ] `tests` — Changes to any test code
- [ ] `tools` — Changes to infrastructure, CI, internal scripts,
debugging tools, etc.
- [ ] `dunno` — I don't know


### Test Plan

1. Test LTR text.
2. Test RTL text.
3. Test mixed LTR/RTL on different lines.

- [ ] Unit Tests
- [x] End to end tests

### Release Notes

- [Add a brief release note for your PR here.](textfields: fix RTL
layout for SVG exports)

---------

Co-authored-by: huppy-bot[bot] <128400622+huppy-bot[bot]@users.noreply.github.com>
This commit is contained in:
Mime Čuvalo 2024-05-03 14:40:59 +01:00 committed by GitHub
parent c308cc2edd
commit 68bc29f103
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 37 additions and 3 deletions

View file

@ -33,6 +33,17 @@ test.describe('Export snapshots', () => {
}, },
}, },
], ],
'Exports geo text with mixed RTL': [
{
id: 'shape:testShape' as TLShapeId,
type: 'geo',
props: {
w: 300,
h: 300,
text: 'unicode is cool!\nكتابة باللغة العرب!',
},
},
],
} as Record<string, TLShapePartial[]> } as Record<string, TLShapePartial[]>
for (const fill of ['none', 'semi', 'solid', 'pattern']) { for (const fill of ['none', 'semi', 'solid', 'pattern']) {

View file

@ -79,7 +79,10 @@ export class TextManager {
const elm = this.baseElm?.cloneNode() as HTMLDivElement const elm = this.baseElm?.cloneNode() as HTMLDivElement
this.baseElm.insertAdjacentElement('afterend', elm) this.baseElm.insertAdjacentElement('afterend', elm)
elm.setAttribute('dir', 'ltr') elm.setAttribute('dir', 'auto')
// N.B. This property, while discouraged ("intended for Document Type Definition (DTD) designers")
// is necessary for ensuring correct mixed RTL/LTR behavior when exporting SVGs.
elm.style.setProperty('unicode-bidi', 'plaintext')
elm.style.setProperty('font-family', opts.fontFamily) elm.style.setProperty('font-family', opts.fontFamily)
elm.style.setProperty('font-style', opts.fontStyle) elm.style.setProperty('font-style', opts.fontStyle)
elm.style.setProperty('font-weight', opts.fontWeight) elm.style.setProperty('font-weight', opts.fontWeight)
@ -130,6 +133,7 @@ export class TextManager {
let currentSpan = null let currentSpan = null
let prevCharWasSpaceCharacter = null let prevCharWasSpaceCharacter = null
let prevCharTop = 0 let prevCharTop = 0
let prevCharLeftForRTLTest = 0
let didTruncate = false let didTruncate = false
for (const childNode of element.childNodes) { for (const childNode of element.childNodes) {
if (childNode.nodeType !== Node.TEXT_NODE) continue if (childNode.nodeType !== Node.TEXT_NODE) continue
@ -148,6 +152,7 @@ export class TextManager {
const top = rect.top + offsetY const top = rect.top + offsetY
const left = rect.left + offsetX const left = rect.left + offsetX
const right = rect.right + offsetX const right = rect.right + offsetX
const isRTL = left < prevCharLeftForRTLTest
const isSpaceCharacter = spaceCharacterRegex.test(char) const isSpaceCharacter = spaceCharacterRegex.test(char)
if ( if (
@ -175,12 +180,22 @@ export class TextManager {
box: { x: left, y: top, w: rect.width, h: rect.height }, box: { x: left, y: top, w: rect.width, h: rect.height },
text: char, text: char,
} }
prevCharLeftForRTLTest = left
} else { } else {
// Looks like we're in RTL mode, so we need to adjust the left position.
if (isRTL) {
currentSpan.box.x = left
}
// otherwise we just need to extend the current span with the next character // otherwise we just need to extend the current span with the next character
currentSpan.box.w = right - currentSpan.box.x currentSpan.box.w = isRTL ? currentSpan.box.w + rect.width : right - currentSpan.box.x
currentSpan.text += char currentSpan.text += char
} }
if (char === '\n') {
prevCharLeftForRTLTest = 0
}
prevCharWasSpaceCharacter = isSpaceCharacter prevCharWasSpaceCharacter = isSpaceCharacter
prevCharTop = top prevCharTop = top
idx += char.length idx += char.length
@ -213,9 +228,12 @@ export class TextManager {
this.baseElm.insertAdjacentElement('afterend', elm) this.baseElm.insertAdjacentElement('afterend', elm)
const elementWidth = Math.ceil(opts.width - opts.padding * 2) const elementWidth = Math.ceil(opts.width - opts.padding * 2)
elm.setAttribute('dir', 'auto')
// N.B. This property, while discouraged ("intended for Document Type Definition (DTD) designers")
// is necessary for ensuring correct mixed RTL/LTR behavior when exporting SVGs.
elm.style.setProperty('unicode-bidi', 'plaintext')
elm.style.setProperty('width', `${elementWidth}px`) elm.style.setProperty('width', `${elementWidth}px`)
elm.style.setProperty('height', 'min-content') elm.style.setProperty('height', 'min-content')
elm.style.setProperty('dir', 'ltr')
elm.style.setProperty('font-size', `${opts.fontSize}px`) elm.style.setProperty('font-size', `${opts.fontSize}px`)
elm.style.setProperty('font-family', opts.fontFamily) elm.style.setProperty('font-family', opts.fontFamily)
elm.style.setProperty('font-weight', opts.fontWeight) elm.style.setProperty('font-weight', opts.fontWeight)

View file

@ -75,6 +75,9 @@ export function createTextJsxFromSpans(
alignmentBaseline="mathematical" alignmentBaseline="mathematical"
x={box.x + offsetX} x={box.x + offsetX}
y={box.y + offsetY} y={box.y + offsetY}
// N.B. This property, while discouraged ("intended for Document Type Definition (DTD) designers")
// is necessary for ensuring correct mixed RTL/LTR behavior when exporting SVGs.
unicodeBidi="plaintext"
> >
{correctSpacesToNbsp(text)} {correctSpacesToNbsp(text)}
</tspan> </tspan>

View file

@ -86,6 +86,7 @@ exports[`Matches a snapshot: Basic SVG 1`] = `
> >
<tspan <tspan
alignment-baseline="mathematical" alignment-baseline="mathematical"
unicode-bidi="plaintext"
x="16" x="16"
y="-17" y="-17"
> >
@ -103,6 +104,7 @@ exports[`Matches a snapshot: Basic SVG 1`] = `
> >
<tspan <tspan
alignment-baseline="mathematical" alignment-baseline="mathematical"
unicode-bidi="plaintext"
x="16" x="16"
y="-17" y="-17"
> >