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:
parent
c308cc2edd
commit
68bc29f103
6 changed files with 37 additions and 3 deletions
|
@ -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']) {
|
||||||
|
|
Binary file not shown.
After Width: | Height: | Size: 13 KiB |
Binary file not shown.
After Width: | Height: | Size: 13 KiB |
|
@ -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)
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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"
|
||||||
>
|
>
|
||||||
|
|
Loading…
Reference in a new issue