diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift index 6f2428a6a7de..1ebc79bd4bca 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift @@ -69,6 +69,9 @@ public class T3ComposerEditorModule: Module { Prop("contentInsetVertical") { (view: T3ComposerEditorView, contentInsetVertical: Double) in view.setContentInsetVertical(CGFloat(contentInsetVertical)) } + Prop("writingDirection") { (view: T3ComposerEditorView, writingDirection: String) in + view.setWritingDirection(writingDirection) + } Prop("editable") { (view: T3ComposerEditorView, editable: Bool) in view.setEditable(editable) } diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift index 50ac2afbcb46..000265077b58 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift @@ -427,6 +427,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro private var fontSize: CGFloat = 14 private var lineHeight: CGFloat = 20 private var contentInsetVertical: CGFloat = 0 + private var isRightToLeft = false private var shouldAutoFocus = false private var didAutoFocus = false private var isReadOnly = false @@ -667,6 +668,31 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro setNeedsLayout() } + // Live composer direction, decided in JS from the draft's first strong + // letter. UIKit does not re-resolve a text view's base direction from its + // content, and restoreBaseTypingAttributes would clobber any keyboard-driven + // direction anyway — so the direction rides the base paragraph style, which + // TextKit's natural alignment follows, and the existing text is restyled in + // place (never rebuilt: a rebuild from the controlled value could race a + // keystroke the revision guard has not acknowledged yet). + func setWritingDirection(_ writingDirection: String) { + let isRTL = writingDirection == "rtl" + guard isRTL != isRightToLeft else { + return + } + isRightToLeft = isRTL + placeholderLabel.textAlignment = isRTL ? .right : .left + let storageRange = NSRange(location: 0, length: textView.textStorage.length) + if storageRange.length > 0 { + textView.textStorage.addAttribute( + .paragraphStyle, + value: baseParagraphStyle(), + range: storageRange + ) + } + restoreBaseTypingAttributes() + } + func setEditable(_ editable: Bool) { textView.isEditable = editable } @@ -1055,16 +1081,22 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro return nil } - private func baseAttributes() -> [NSAttributedString.Key: Any] { - let font = UIFont(name: fontFamily, size: fontSize) - ?? UIFont.systemFont(ofSize: fontSize) + private func baseParagraphStyle() -> NSParagraphStyle { let paragraph = NSMutableParagraphStyle() paragraph.minimumLineHeight = lineHeight paragraph.maximumLineHeight = lineHeight + paragraph.baseWritingDirection = isRightToLeft ? .rightToLeft : .leftToRight + paragraph.alignment = .natural + return paragraph + } + + private func baseAttributes() -> [NSAttributedString.Key: Any] { + let font = UIFont(name: fontFamily, size: fontSize) + ?? UIFont.systemFont(ofSize: fontSize) return [ .font: font, .foregroundColor: UIColor(composerHex: theme.text) ?? .label, - .paragraphStyle: paragraph, + .paragraphStyle: baseParagraphStyle(), ] } diff --git a/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt b/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt index 26ceb2023235..1e43ab5e7da3 100644 --- a/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt +++ b/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt @@ -33,6 +33,10 @@ import java.io.ByteArrayOutputStream import kotlin.math.ceil private const val OBJECT_REPLACEMENT_CHARACTER = "\uFFFC" +// The markdown renderer wraps inline code in an RTL paragraph with an LTR bidi +// isolate (LRI \u2066 \u2026 PDI \u2069) so it renders left-to-right without a Text +// ref call per span. Invisible in the UI, but must not leak into a paste. +private val BIDI_ISOLATE_CHARACTERS = setOf('\u2066', '\u2069') // Match React Native's measurement buffer. Android orders tied line-height // spans differently in SpannableString, shifting inline images once RN's @@ -47,7 +51,13 @@ internal fun copyTextWithoutInlineImages( start: Int, end: Int ): String { - if (text !is Spanned) return text.subSequence(start, end).toString() + if (text !is Spanned) { + return buildString { + for (index in start until end) { + if (text[index] !in BIDI_ISOLATE_CHARACTERS) append(text[index]) + } + } + } fun isInlineImage(index: Int): Boolean = index >= 0 && text[index].toString() == OBJECT_REPLACEMENT_CHARACTER && @@ -58,7 +68,9 @@ internal fun copyTextWithoutInlineImages( // The renderer inserts one NBSP after each image to keep its label on the same line. // Inspect the original text even when selection starts after the image. val isIconSpacer = text[index] == '\u00A0' && isInlineImage(index - 1) - if (!isInlineImage(index) && !isIconSpacer) append(text[index]) + if (!isInlineImage(index) && !isIconSpacer && text[index] !in BIDI_ISOLATE_CHARACTERS) { + append(text[index]) + } } } } @@ -81,7 +93,12 @@ private fun canonicalSelection( hasContext = true } } - return if (hasContext) canonical.toString().replace(OBJECT_REPLACEMENT_CHARACTER, "") else null + if (!hasContext) return null + var sanitized = canonical.toString().replace(OBJECT_REPLACEMENT_CHARACTER, "") + for (isolate in BIDI_ISOLATE_CHARACTERS) { + sanitized = sanitized.replace(isolate.toString(), "") + } + return sanitized } private fun selectedContextRecords(records: JSONArray, selectedText: String): JSONArray { diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm index 533c13108865..0ac62e5b6331 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm @@ -27,7 +27,13 @@ - (BOOL)accessibilityActivate } @end -/** Preserve canonical references and their payload when copying a native text selection. */ +/** + * Preserve canonical references and their payload when copying a native text + * selection. Also strips characters the markdown renderer embeds but that + * must never leak into a paste: the object-replacement character used for + * file/skill icon placeholders, and the LTR bidi isolate (LRI \u2066 / PDI + * \u2069) inline code gets wrapped in inside RTL paragraphs. + */ @interface T3ContextCopyTextView : UITextView @property(nonatomic, copy) NSDictionary *contextClipboardConfig; @end @@ -45,12 +51,14 @@ - (BOOL)canPerformAction:(SEL)action withSender:(id)sender - (void)copy:(id)sender { NSRange selected = self.selectedRange; - NSArray *ranges = self.contextClipboardConfig[@"ranges"]; - if (selected.location == NSNotFound || selected.length == 0 || NSMaxRange(selected) > self.text.length || ranges.count == 0) { + if (selected.location == NSNotFound || selected.length == 0 || NSMaxRange(selected) > self.text.length) { [super copy:sender]; return; } - NSMutableString *text = [[self.text substringWithRange:selected] mutableCopy]; + + NSString *originalText = [self.text substringWithRange:selected]; + NSArray *ranges = self.contextClipboardConfig[@"ranges"]; + NSMutableString *text = [originalText mutableCopy]; BOOL hasContext = NO; for (NSDictionary *range in [ranges reverseObjectEnumerator]) { NSUInteger start = [range[@"start"] unsignedIntegerValue]; @@ -61,8 +69,20 @@ - (void)copy:(id)sender [text replaceCharactersInRange:NSMakeRange(overlap.location - selected.location, overlap.length) withString:range[@"text"]]; hasContext = YES; } - if (!hasContext) { [super copy:sender]; return; } + [text replaceOccurrencesOfString:@"\uFFFC\u00A0" withString:@"" options:0 range:NSMakeRange(0, text.length)]; + [text replaceOccurrencesOfString:@"\uFFFC" withString:@"" options:0 range:NSMakeRange(0, text.length)]; + [text replaceOccurrencesOfString:@"\u2066" withString:@"" options:0 range:NSMakeRange(0, text.length)]; + [text replaceOccurrencesOfString:@"\u2069" withString:@"" options:0 range:NSMakeRange(0, text.length)]; + + if (!hasContext) { + if ([text isEqualToString:originalText]) { + [super copy:sender]; + } else { + UIPasteboard.generalPasteboard.string = text; + } + return; + } NSString *fragment = self.contextClipboardConfig[@"fragment"]; NSMutableDictionary *payload = [[NSJSONSerialization JSONObjectWithData:[fragment dataUsingEncoding:NSUTF8StringEncoding] options:NSJSONReadingMutableContainers error:nil] mutableCopy]; NSArray *records = payload[@"records"]; @@ -113,8 +133,13 @@ static void T3MarkdownTextApplyParagraphStyles( paragraphStyle.firstLineHeadIndent = styleRange.firstLineHeadIndent; paragraphStyle.headIndent = styleRange.headIndent; paragraphStyle.paragraphSpacing = styleRange.paragraphSpacing; + if (styleRange.rtl) { + paragraphStyle.baseWritingDirection = NSWritingDirectionRightToLeft; + } + // Must match applyParagraphStyles in T3MarkdownTextShadowNode.mm (measure path). paragraphStyle.tabStops = @[ - [[NSTextTab alloc] initWithTextAlignment:NSTextAlignmentLeft + [[NSTextTab alloc] initWithTextAlignment:styleRange.rtl ? NSTextAlignmentRight + : NSTextAlignmentLeft location:styleRange.headIndent options:@{}] ]; diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h index 0f7b284594bb..2a278f19bb9e 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h @@ -20,6 +20,10 @@ struct T3MarkdownTextParagraphStyleRange { Float firstLineHeadIndent; Float headIndent; Float paragraphSpacing; + // RTL paragraphs (Hebrew/Arabic list items) need their base writing direction + // pinned and their marker tab stop right-aligned; TextKit flips the head + // indents to the leading (right) edge on its own. + bool rtl; }; struct T3MarkdownTextAttachmentRange { diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm index e1cc7c2046b2..ad4d05527515 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm @@ -36,8 +36,14 @@ static void applyParagraphStyles( paragraphStyle.firstLineHeadIndent = styleRange.firstLineHeadIndent; paragraphStyle.headIndent = styleRange.headIndent; paragraphStyle.paragraphSpacing = styleRange.paragraphSpacing; + if (styleRange.rtl) { + paragraphStyle.baseWritingDirection = NSWritingDirectionRightToLeft; + } + // The tab stop's alignment matches the paragraph's writing direction so the + // list-marker column sits on the leading edge (right, for RTL paragraphs). paragraphStyle.tabStops = @[ - [[NSTextTab alloc] initWithTextAlignment:NSTextAlignmentLeft + [[NSTextTab alloc] initWithTextAlignment:styleRange.rtl ? NSTextAlignmentRight + : NSTextAlignmentLeft location:styleRange.headIndent options:@{}] ]; @@ -179,6 +185,15 @@ static void applyAttachments( textAttributes.alignment = TextAlignment::Natural; } + // Natural alignment follows the paragraph's base writing direction, so an + // explicit "rtl" run right-aligns and reorders as Hebrew/Arabic prose while + // "ltr" pins code, and "auto" keeps TextKit's first-strong resolution. + if (props.writingDirection == T3MarkdownTextRunWritingDirection::Ltr) { + textAttributes.baseWritingDirection = WritingDirection::LeftToRight; + } else if (props.writingDirection == T3MarkdownTextRunWritingDirection::Rtl) { + textAttributes.baseWritingDirection = WritingDirection::RightToLeft; + } + textAttributes.backgroundColor = props.backgroundColor; fragment.string = props.text; @@ -193,6 +208,7 @@ static void applyAttachments( props.shadowOffset.width, props.shadowOffset.height, props.shadowRadius - ParagraphStyleEncodingOffset, + props.writingDirection == T3MarkdownTextRunWritingDirection::Rtl, }); } if (props.nativeId.rfind("t3-chip:", 0) == 0 && fragmentLength > 0) { diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.tsx index a4485ede4705..997ce6abffdf 100644 --- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.tsx @@ -5,9 +5,11 @@ import type { MarkdownNode } from "react-native-nitro-markdown/headless"; import { CopyTextButton } from "./CopyTextButton"; import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive"; import { + markdownBlockDirection, nativeMarkdownDocumentRuns, nativeMarkdownListItemBlocks, nativeMarkdownNodePosition, + type MarkdownWritingDirection, } from "./nativeMarkdownText"; import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText"; import type { @@ -57,10 +59,11 @@ function SelectableNode(props: { readonly skills: ReadonlyArray; readonly textStyle: NativeMarkdownTextStyle; readonly onLinkPress?: (href: string) => void; + readonly direction?: MarkdownWritingDirection; }) { return ( @@ -114,6 +117,8 @@ function HighlightedCodeText(props: { fontFamily: MONO_FONT_FAMILY, fontSize, lineHeight, + // Code stays LTR always — a Hebrew comment must not flip the snippet. + writingDirection: "ltr" as const, }), [props.textStyle.codeColor, fontSize, lineHeight], ); @@ -376,6 +381,7 @@ function NativeMixedParagraph(props: { readonly skills: ReadonlyArray; readonly textStyle: NativeMarkdownTextStyle; readonly onLinkPress?: (href: string) => void; + readonly direction?: MarkdownWritingDirection; }) { return ( @@ -395,6 +401,7 @@ function NativeMixedParagraph(props: { skills={props.skills} textStyle={props.textStyle} onLinkPress={props.onLinkPress} + direction={props.direction} /> ), )} @@ -409,6 +416,7 @@ function NativeList(props: { readonly highlightCode: MarkdownCodeHighlighter; readonly onLinkPress?: (href: string) => void; readonly depth: number; + readonly direction?: MarkdownWritingDirection; }) { const ordered = props.node.ordered ?? false; const start = props.node.start ?? 1; @@ -420,6 +428,11 @@ function NativeList(props: { }} > {(props.node.children ?? []).map((item, index) => { + // Each item resolves its own direction — inherited from the enclosing + // block, or from the item's own first strong letter — so a Hebrew item + // in an English list still gets its marker on the right, and vice versa. + const itemDirection = props.direction ?? markdownBlockDirection(item); + const rtl = itemDirection === "rtl"; const taskMarker = item.type === "task_list_item"; const marker = taskMarker ? item.checked @@ -437,14 +450,15 @@ function NativeList(props: { return ( @@ -471,6 +485,7 @@ function NativeList(props: { highlightCode={props.highlightCode} onLinkPress={props.onLinkPress} depth={props.depth + 1} + direction={itemDirection} compact /> ))} @@ -490,6 +505,7 @@ export function NativeMarkdownBlock(props: { readonly onLinkPress?: (href: string) => void; readonly depth?: number; readonly compact?: boolean; + readonly direction?: MarkdownWritingDirection; }) { const depth = props.depth ?? 0; switch (props.node.type) { @@ -505,6 +521,7 @@ export function NativeMarkdownBlock(props: { highlightCode={props.highlightCode} onLinkPress={props.onLinkPress} depth={depth} + direction={props.direction} /> ))} @@ -545,14 +562,20 @@ export function NativeMarkdownBlock(props: { }} /> ); - case "blockquote": + case "blockquote": { + // The quote bar sits on the leading edge of its own text: right for a + // Hebrew/Arabic quote, left otherwise (per-block, like the web's dir="auto"). + const rtl = (props.direction ?? markdownBlockDirection(props.node)) === "rtl"; return ( ))} ); + } case "list": return ( ); case "paragraph": @@ -589,6 +615,7 @@ export function NativeMarkdownBlock(props: { skills={props.skills} textStyle={props.textStyle} onLinkPress={props.onLinkPress} + direction={props.direction} /> ) : ( ); case "html_block": diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx index 50a381bae288..e5669102ce0f 100644 --- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx @@ -84,6 +84,7 @@ function runKeySignature(run: NativeMarkdownTextRun): string { run.firstLineHeadIndent, run.headIndent, run.paragraphSpacing, + run.writingDirection, ].join(":"); } @@ -174,6 +175,10 @@ function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle fontStyle: run.italic ? "italic" : "normal", fontWeight: isHeading || run.bold || isFile || isSkill ? "700" : "400", textDecorationLine, + // Per-block bidi: the block's own first strong letter decided this in + // nativeMarkdownText.ts, and the native side turns it into the paragraph's + // base writing direction (which natural alignment then follows). + ...(run.writingDirection ? { writingDirection: run.writingDirection } : {}), backgroundColor: isCodeBlock ? textStyle.codeBlockBackgroundColor : parseComposerContextHref(run.href ?? "") diff --git a/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextRunNativeComponent.ts b/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextRunNativeComponent.ts index 040e44bc18b5..70b9c6360d75 100644 --- a/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextRunNativeComponent.ts +++ b/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextRunNativeComponent.ts @@ -32,6 +32,8 @@ type FontStyle = "normal" | "italic"; type TextAlign = "auto" | "left" | "right" | "center" | "justify"; +type WritingDirection = "auto" | "ltr" | "rtl"; + interface NativeProps extends ViewProps { text: string; color?: ColorValue; @@ -45,6 +47,7 @@ interface NativeProps extends ViewProps { textDecorationStyle?: WithDefault; textDecorationColor?: ColorValue; textAlign?: WithDefault; + writingDirection?: WithDefault; shadowRadius?: WithDefault; contextMenuConfig?: string; onPress?: BubblingEventHandler; diff --git a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.test.ts b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.test.ts new file mode 100644 index 000000000000..b037d6b8c9cb --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.test.ts @@ -0,0 +1,267 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { MarkdownNode } from "react-native-nitro-markdown/headless"; + +import { + firstStrongDirection, + markdownBlockDirection, + nativeMarkdownDocumentRuns, + resolvedTextDirection, +} from "./nativeMarkdownText"; + +const HEBREW_MIXED = "שלום, זה טקסט בעברית עם מונח באנגלית כמו Claude Code בתוכו."; + +function text(content: string): MarkdownNode { + return { type: "text", content }; +} + +function paragraph(...children: MarkdownNode[]): MarkdownNode { + return { type: "paragraph", children }; +} + +function document(...children: MarkdownNode[]): MarkdownNode { + return { type: "document", children }; +} + +describe("firstStrongDirection", () => { + it("resolves Hebrew and Arabic text as RTL", () => { + expect(firstStrongDirection("שלום עולם")).toBe("rtl"); + expect(firstStrongDirection("مرحبا بالعالم")).toBe("rtl"); + }); + + it("resolves Latin text as LTR", () => { + expect(firstStrongDirection("Hello world")).toBe("ltr"); + }); + + it("lets the first letter decide when languages mix", () => { + expect(firstStrongDirection(HEBREW_MIXED)).toBe("rtl"); + expect(firstStrongDirection("Claude Code זה כלי")).toBe("ltr"); + }); + + it("skips neutral digits, punctuation and symbols", () => { + expect(firstStrongDirection('42 - "שלום"')).toBe("rtl"); + expect(firstStrongDirection("3. Hello")).toBe("ltr"); + }); + + it("defaults to LTR when no letter exists", () => { + expect(firstStrongDirection("")).toBe("ltr"); + expect(firstStrongDirection("123 !?")).toBe("ltr"); + }); +}); + +describe("resolvedTextDirection", () => { + it("reads Hebrew that opens with a URL right-to-left", () => { + expect(resolvedTextDirection("https://claude.ai זה האתר של קלוד")).toBe("rtl"); + }); + + it("reads Hebrew that opens with a file name right-to-left", () => { + expect(resolvedTextDirection("server.py זה הקובץ הראשי")).toBe("rtl"); + }); + + it("reads Hebrew that opens with a path right-to-left", () => { + expect(resolvedTextDirection("src/main.ts זה הקובץ שצריך לערוך")).toBe("rtl"); + }); + + it("reads Hebrew that opens with an inline-code span right-to-left", () => { + expect(resolvedTextDirection("`git status` תריץ קודם")).toBe("rtl"); + }); + + it("keeps English with one Hebrew word left-to-right", () => { + expect(resolvedTextDirection("The word שלום means hello")).toBe("ltr"); + }); + + it("keeps pure English left-to-right", () => { + expect(resolvedTextDirection("Hello world")).toBe("ltr"); + }); + + it("keeps Hebrew-first text right-to-left, unchanged", () => { + expect(resolvedTextDirection(HEBREW_MIXED)).toBe("rtl"); + }); + + it("keeps plain English words before Hebrew left-to-right (no tech token)", () => { + // Latin letters hold the majority here, so the leading English words decide. + expect(resolvedTextDirection("Claude Code זה כלי")).toBe("ltr"); + }); + + it("reads a Hebrew sentence that opens with a Latin prose label right-to-left", () => { + expect( + resolvedTextDirection('Next step (ישן): "מתחילים לבנות תחנה 1, לאט. החוסמים: 3 קבצים."'), + ).toBe("rtl"); + expect(resolvedTextDirection("TL;DR: הפיצ׳ר עובד, נשאר רק לנקות את הקוד")).toBe("rtl"); + // A Latin-majority sentence quoting some Hebrew still reads left-to-right. + expect(resolvedTextDirection("The customer wrote שלום וברכה in the ticket")).toBe("ltr"); + }); + + it("discounts quoted and parenthesized Latin citations from the vote", () => { + expect(resolvedTextDirection('PROFILE — הוספתי סעיף "Build-feedback call additions"')).toBe( + "rtl", + ); + expect(resolvedTextDirection("P1 — אסטרטגיות (product-lens):")).toBe("rtl"); + // A Hebrew quotation inside English prose keeps its vote — still LTR. + expect(resolvedTextDirection('They titled it "ברוכים הבאים" and moved on quickly')).toBe("ltr"); + }); +}); + +describe("markdownBlockDirection", () => { + it("discounts a leading file name in plain paragraph text", () => { + expect(markdownBlockDirection(paragraph(text("server.py זה הקובץ הראשי")))).toBe("rtl"); + }); + + it("discounts a leading URL in plain paragraph text", () => { + expect(markdownBlockDirection(paragraph(text("https://claude.ai האתר של קלוד")))).toBe("rtl"); + }); + it("reads the block's own text content", () => { + expect(markdownBlockDirection(paragraph(text("שלום")))).toBe("rtl"); + expect(markdownBlockDirection(paragraph(text("Hello")))).toBe("ltr"); + }); + + it("ignores code and tables when resolving the direction", () => { + expect( + markdownBlockDirection( + paragraph({ type: "code_inline", content: "npm install" }, text(" שלום")), + ), + ).toBe("rtl"); + expect( + markdownBlockDirection( + document({ type: "code_block", content: "שגיאה = 1" }, paragraph(text("Hello"))), + ), + ).toBe("ltr"); + }); + + it("ignores HTML tag names, but not the text they wrap", () => { + expect(markdownBlockDirection(paragraph({ type: "html_inline", content: "שלום" }))).toBe( + "rtl", + ); + }); +}); + +describe("nativeMarkdownDocumentRuns direction", () => { + it("marks a Hebrew paragraph RTL and an English one LTR in the same document", () => { + const runs = nativeMarkdownDocumentRuns( + document(paragraph(text(HEBREW_MIXED)), paragraph(text("An English paragraph."))), + ); + const hebrew = runs.find((run) => run.text.includes("שלום")); + const english = runs.find((run) => run.text.includes("English")); + expect(hebrew?.writingDirection).toBe("rtl"); + expect(english?.writingDirection).toBe("ltr"); + }); + + it("gives every list item its own direction, markers included", () => { + const runs = nativeMarkdownDocumentRuns( + document({ + type: "list", + ordered: false, + children: [ + { type: "list_item", children: [paragraph(text("פריט ראשון"))] }, + { type: "list_item", children: [paragraph(text("Item in English"))] }, + ], + }), + ); + // Mixed lists keep each marker beside the text it labels (web: per-item dir). + const hebrewItem = runs.find((run) => run.text.includes("פריט")); + const englishItem = runs.find((run) => run.text.includes("English")); + expect(hebrewItem?.writingDirection).toBe("rtl"); + expect(englishItem?.writingDirection).toBe("ltr"); + const markers = runs.filter((run) => run.role === "list-marker"); + expect(markers.map((run) => run.writingDirection)).toEqual(["rtl", "ltr"]); + }); + + it("inherits the outer direction into nested lists", () => { + const runs = nativeMarkdownDocumentRuns( + document({ + type: "list", + ordered: false, + children: [ + { + type: "list_item", + children: [ + paragraph(text("רשימה בעברית")), + { + type: "list", + ordered: false, + children: [{ type: "list_item", children: [paragraph(text("English nested"))] }], + }, + ], + }, + ], + }), + ); + for (const run of runs) { + expect(run.writingDirection).toBe("rtl"); + } + }); + + it("isolates Latin runs inside RTL text so surrounding punctuation stays put", () => { + const runs = nativeMarkdownDocumentRuns( + document(paragraph(text('הבוט "סותר את Kapso" וגם U1+U2+U3+U5, ההסלמה'))), + ); + const body = runs.find((run) => run.text.includes("Kapso")); + expect(body?.text).toContain("⁦Kapso⁩"); + expect(body?.text).toContain("⁦U1+U2+U3+U5⁩"); + // The closing quote and the comma stay outside the isolates. + expect(body?.text).toContain('⁦Kapso⁩"'); + expect(body?.text).toContain("⁩, ההסלמה"); + }); + + it("leaves LTR text without isolates", () => { + const runs = nativeMarkdownDocumentRuns(document(paragraph(text("Plain English text here")))); + expect(runs[0]?.text).not.toContain("⁦"); + }); + + it("marks a Hebrew heading RTL", () => { + const runs = nativeMarkdownDocumentRuns( + document({ type: "heading", level: 2, children: [text("כותרת בעברית")] }), + ); + expect(runs[0]?.writingDirection).toBe("rtl"); + }); + + it("pins code blocks LTR even when their content is Hebrew", () => { + const runs = nativeMarkdownDocumentRuns( + document(paragraph(text("הסבר בעברית")), { + type: "code_block", + language: "js", + content: '// הערה בעברית\nconst x = "שלום";\n', + }), + ); + for (const run of runs.filter( + (item) => item.role === "code-block" || item.role === "code-language", + )) { + expect(run.writingDirection).toBe("ltr"); + } + }); + + it("keeps a blockquote one directional unit", () => { + const runs = nativeMarkdownDocumentRuns( + document({ + type: "blockquote", + children: [paragraph(text("ציטוט בעברית")), paragraph(text("English continuation"))], + }), + ); + for (const run of runs) { + expect(run.writingDirection).toBe("rtl"); + } + }); + + it("wraps inline code inside an RTL paragraph in an LTR isolate", () => { + const runs = nativeMarkdownDocumentRuns( + document( + paragraph(text("תריץ "), { type: "code_inline", content: "git status" }, text(" עכשיו")), + ), + ); + const code = runs.find((run) => run.code); + expect(code?.text).toBe("\u2066git status\u2069"); + expect(code?.writingDirection).toBe("rtl"); + }); + + it("leaves inline code inside an LTR paragraph untouched", () => { + const runs = nativeMarkdownDocumentRuns( + document(paragraph(text("Run "), { type: "code_inline", content: "git status" })), + ); + const code = runs.find((run) => run.code); + expect(code?.text).toBe("git status"); + }); + + it("honors an explicitly inherited direction", () => { + const runs = nativeMarkdownDocumentRuns(document(paragraph(text("English text"))), [], "rtl"); + expect(runs[0]?.writingDirection).toBe("rtl"); + }); +}); diff --git a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts index ec8cf74fee2b..b10260c6e53c 100644 --- a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts +++ b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts @@ -129,6 +129,8 @@ import { type MarkdownFileIcon, } from "./markdownLinks"; +export type MarkdownWritingDirection = "ltr" | "rtl"; + export interface NativeMarkdownTextRun { readonly text: string; readonly bold?: boolean; @@ -157,6 +159,7 @@ export interface NativeMarkdownTextRun { readonly firstLineHeadIndent?: number; readonly headIndent?: number; readonly paragraphSpacing?: number; + readonly writingDirection?: MarkdownWritingDirection; } export type NativeMarkdownDocumentChunk = @@ -186,6 +189,7 @@ interface RunContext { readonly firstLineHeadIndent?: number; readonly headIndent?: number; readonly paragraphSpacing?: number; + readonly writingDirection?: MarkdownWritingDirection; } const EMPTY_CONTEXT: RunContext = { @@ -197,6 +201,95 @@ const EMPTY_CONTEXT: RunContext = { const INLINE_HTML_TAG_PATTERN = /<\/?(?:kbd|mark|sub|sup|u)(?:\s[^>]*)?>/gi; +// Strong-RTL code points: Hebrew, Arabic, Syriac, Thaana, NKo, Samaritan, Mandaic and +// their extensions/presentation forms, plus the astral RTL blocks (Phoenician … Adlam). +const STRONG_RTL_CHAR = /[֐-ࣿיִ-﷿ﹰ-\u{10800}-\u{10FFF}\u{1E800}-\u{1EFFF}]/u; +// First letter decides (UBA P2/P3): digits, punctuation and symbols are neutral. +const FIRST_LETTER = /\p{L}/u; + +// The direction a block of text renders in — what the web app's `dir="auto"` would resolve. +export function firstStrongDirection(text: string): MarkdownWritingDirection { + const letter = FIRST_LETTER.exec(text)?.[0]; + return letter && STRONG_RTL_CHAR.test(letter) ? "rtl" : "ltr"; +} + +// The Latin spans that must not get the direction vote: tech tokens a Hebrew +// sentence often *opens* with (a URL, an inline-code span, a path, a file name +// — "server.py זה הקובץ הראשי"), plus quoted or parenthesized Latin — a cited +// title or gloss ('הוספתי סעיף "Build-feedback call additions"', "אסטרטגיות +// (product-lens)") names a thing rather than continuing the prose. Mirrors the +// web app's pattern (each app keeps its own copy — no cross-app imports) and +// stripLeadingLTR from the claude-desktop-rtl-patch. +const LTR_TECH_TOKEN = + /https?:\/\/\S+|`[^`\n]+`|\S*[/\\]\S+|\b\w+\.\w{1,5}\b|"[^"\n]+"|[“«][^”»\n]+[”»]|\([^()\n]+\)/gu; + +function stripLtrTechTokens(text: string): string { + // A span carrying its own strong-RTL letters (an RTL slash pair like כן/לא, + // a Hebrew quotation) is prose, not a citation — it keeps its vote. + return text.replace(LTR_TECH_TOKEN, (token) => (STRONG_RTL_CHAR.test(token) ? token : " ")); +} + +// The last-resort vote: which strong script owns most of the text's letters. +// Counted per letter (`\p{L}`), so neutral digits/punctuation and RTL combining +// marks (niqqud, harakat — marks, not letters) never tilt the tally. +function rtlLetterMajority(text: string): boolean { + let balance = 0; + for (const letter of text.match(/\p{L}/gu) ?? []) { + balance += STRONG_RTL_CHAR.test(letter) ? 1 : -1; + } + return balance > 0; +} + +// First-strong, with two corrections for RTL prose that *opens* with Latin: +// a leading tech token (URL, path, file name) never gets the first-strong vote, +// and a text whose letters are mostly RTL is RTL even when it leads with a +// Latin prose label — "**Next step (ישן):** מתחילים לבנות…" is a Hebrew +// sentence, and reading it LTR strands its closing punctuation on the wrong +// side. A mostly-English text with a few Hebrew words stays LTR — its Latin +// letters keep the majority. +export function resolvedTextDirection(text: string): MarkdownWritingDirection { + if (firstStrongDirection(text) === "rtl") { + return "rtl"; + } + if (!STRONG_RTL_CHAR.test(text)) { + return "ltr"; + } + const stripped = stripLtrTechTokens(text); + if (firstStrongDirection(stripped) === "rtl") { + return "rtl"; + } + return rtlLetterMajority(stripped) ? "rtl" : "ltr"; +} + +// Code and tables opt out of direction detection and stay LTR: their shape is not +// prose, so their letters must not decide the direction of the block around them — +// the same nodes the web app pins with an explicit `dir="ltr"` (which `dir="auto"` +// then skips when resolving an ancestor). +const DIRECTION_NEUTRAL_NODE_TYPES = new Set(["code_block", "code_inline", "table"]); + +function directionSourceText(node: MarkdownNode): string { + if (DIRECTION_NEUTRAL_NODE_TYPES.has(node.type)) { + return ""; + } + if (node.type === "html_inline" || node.type === "html_block") { + // Tag names are letters too — only the text an HTML node renders may vote. + return inlineHtmlText(nodeTextContent(node)); + } + if (node.content !== undefined) { + return node.content; + } + return (node.children ?? []).map(directionSourceText).join(""); +} + +// The base direction of a markdown block, resolved from the block's own first +// strong letter (mirroring the web renderer's per-block `dir="auto"`) — with +// leading Latin tech tokens discounted. Code spans are already excluded +// structurally by directionSourceText; URLs, paths and file names living in +// plain text are handled by the strip fallback. +export function markdownBlockDirection(node: MarkdownNode): MarkdownWritingDirection { + return resolvedTextDirection(directionSourceText(node)); +} + function decodeCodePoint(codePoint: number, entity: string): string { if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) { return entity; @@ -250,11 +343,16 @@ function textNodeContent(value: string): string { return decodeHtmlEntities(value).replace(INLINE_HTML_TAG_PATTERN, ""); } +// Tag-stripping regex that doesn't stop at a `>` inside a quoted attribute +// value (e.g. ``), which would otherwise leak +// attribute text into the direction-detection scan. +const HTML_TAG = /<(?:[^>"']|"[^"]*"|'[^']*')*>/g; + function inlineHtmlText(value: string): string { if (/^$/i.test(value.trim())) { return "\n"; } - return decodeHtmlEntities(value.replace(/<[^>]+>/g, "")); + return decodeHtmlEntities(value.replace(HTML_TAG, "")); } function sameRunStyle(left: NativeMarkdownTextRun, right: NativeMarkdownTextRun): boolean { @@ -274,7 +372,8 @@ function sameRunStyle(left: NativeMarkdownTextRun, right: NativeMarkdownTextRun) left.spacing === right.spacing && left.firstLineHeadIndent === right.firstLineHeadIndent && left.headIndent === right.headIndent && - left.paragraphSpacing === right.paragraphSpacing + left.paragraphSpacing === right.paragraphSpacing && + left.writingDirection === right.writingDirection ); } @@ -307,6 +406,7 @@ function appendRun( ...(context.paragraphSpacing !== undefined ? { paragraphSpacing: context.paragraphSpacing } : {}), + ...(context.writingDirection ? { writingDirection: context.writingDirection } : {}), }; const previous = runs.at(-1); if (previous && sameRunStyle(previous, run)) { @@ -430,27 +530,80 @@ function nodeTextContent(node: MarkdownNode): string { return (node.children ?? []).map(nodeTextContent).join(""); } +// Inside a right-to-left paragraph the bidi algorithm hands the neutrals around +// a Latin run — quotes, commas, a plus sign — to whichever strong run is +// nearer, which strands them on the wrong visual side ('"AIOS" סותר' flips its +// quotes, "U1+U2+U3, ההסלמה" splits the comma off its run). Isolating each run +// (LRI … PDI, the same isolate inline code uses) lets that punctuation resolve +// against the Hebrew it belongs to. A run may span several words joined by +// thin neutrals ("speed-to-lead", "U1+U2+U3+U5", "OpenAI export"); a connector +// is only swallowed when another Latin word follows it, so sentence-final +// punctuation stays outside the isolate. Mirrors the web app's pass. +const LATIN_RUN = /\p{Script=Latin}[\p{Script=Latin}\d]*(?:[ +&/.:'@_-]+[\p{Script=Latin}\d]+)*/gu; + +// A `$`-prefixed span shaped like a skill token ($ui, $2spec \u2014 mirrors +// SKILL_TOKEN_REGEX's own token grammar) must reach decorateSkillRuns intact: +// isolating even one of its interior characters breaks that later regex +// match, silently turning a real skill chip back into plain text. A +// lookbehind keyed off a fixed offset from `$` isn't enough \u2014 the token can +// be longer than one character \u2014 so the whole candidate span is carved out +// before Latin runs elsewhere in the text are isolated. +const SKILL_TOKEN_SPAN = + /\$(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))[a-zA-Z0-9][a-zA-Z0-9:_-]*/g; + +function isolateLatinRuns(text: string): string { + let result = ""; + let cursor = 0; + for (const match of text.matchAll(SKILL_TOKEN_SPAN)) { + const start = match.index ?? 0; + const end = start + match[0].length; + result += text.slice(cursor, start).replace(LATIN_RUN, (run) => `\u2066${run}\u2069`); + result += match[0]; + cursor = end; + } + result += text.slice(cursor).replace(LATIN_RUN, (run) => `\u2066${run}\u2069`); + return result; +} + function appendNode( runs: NativeMarkdownTextRun[], node: MarkdownNode, context: RunContext, ): NativeMarkdownTextRun[] { switch (node.type) { - case "text": + case "text": { + const content = textNodeContent(nodeTextContent(node)); + return appendRun( + runs, + context.writingDirection === "rtl" ? isolateLatinRuns(content) : content, + context, + ); + } case "math_inline": return appendRun(runs, textNodeContent(nodeTextContent(node)), context); - case "html_inline": - return appendRun(runs, inlineHtmlText(nodeTextContent(node)), context); + case "html_inline": { + const content = inlineHtmlText(nodeTextContent(node)); + return appendRun( + runs, + context.writingDirection === "rtl" ? isolateLatinRuns(content) : content, + context, + ); + } case "code_inline": { + // Inline code keeps its left-to-right shape even inside an RTL paragraph + // (the web pins `code` to LTR with CSS). Attributed strings have no + // per-span direction, so wrap the span in an LTR isolate (LRI … PDI). const content = nodeTextContent(node); + const isolate = (value: string) => + context.writingDirection === "rtl" ? `\u2066${value}\u2069` : value; const presentation = context.href ? null : resolveMarkdownInlineCodePresentation(content); return presentation - ? appendRun(runs, presentation.label, { + ? appendRun(runs, isolate(presentation.label), { ...context, href: presentation.href, fileIcon: presentation.icon, }) - : appendRun(runs, content, { ...context, code: true }); + : appendRun(runs, isolate(content), { ...context, code: true }); } case "soft_break": return appendRun(runs, " ", context); @@ -586,6 +739,7 @@ function appendListItem( marker: string, depth: number, markerColumnWidth: number, + writingDirection: MarkdownWritingDirection, ): NativeMarkdownTextRun[] { const firstLineHeadIndent = Math.max(0, depth - 1) * 20; appendRun(runs, `${marker}\t`, { @@ -595,6 +749,7 @@ function appendListItem( firstLineHeadIndent, headIndent: firstLineHeadIndent + markerColumnWidth, paragraphSpacing: 2, + writingDirection, }); const children = node.children ?? []; @@ -605,6 +760,7 @@ function appendListItem( ...EMPTY_CONTEXT, role: "body", depth, + writingDirection, }); wroteInlineContent = true; continue; @@ -616,9 +772,10 @@ function appendListItem( role: "list-break", depth, spacing: 1, + writingDirection, }); } - appendList(runs, child, depth + 1); + appendList(runs, child, depth + 1, writingDirection); wroteInlineContent = false; continue; } @@ -627,11 +784,12 @@ function appendListItem( ...EMPTY_CONTEXT, role: "body", depth, + writingDirection, }); wroteInlineContent = true; continue; } - appendDocumentBlock(runs, child, depth); + appendDocumentBlock(runs, child, depth, writingDirection); wroteInlineContent = true; } @@ -641,6 +799,7 @@ function appendListItem( role: "list-break", depth, spacing: depth === 1 ? 4 : 2, + writingDirection, }); } return runs; @@ -650,6 +809,10 @@ function appendList( runs: NativeMarkdownTextRun[], node: MarkdownNode, depth: number, + // Each item resolves its own direction (a Hebrew item in an English list + // still gets its marker on the right, mirroring the web's per-item `dir`), + // unless the list sits inside an already-claimed block — then it inherits. + inheritedDirection?: MarkdownWritingDirection, ): NativeMarkdownTextRun[] { const ordered = node.ordered ?? false; const start = node.start ?? 1; @@ -681,7 +844,14 @@ function appendList( : marker; const markerColumnWidth = child.type === "task_list_item" ? 28 : ordered ? 10 + markerWidth * 8 : 24; - appendListItem(runs, child, alignedMarker, depth, markerColumnWidth); + appendListItem( + runs, + child, + alignedMarker, + depth, + markerColumnWidth, + inheritedDirection ?? markdownBlockDirection(child), + ); } return runs; } @@ -690,27 +860,30 @@ function appendQuoteBlock( runs: NativeMarkdownTextRun[], node: MarkdownNode, depth: number, + writingDirection: MarkdownWritingDirection, ): NativeMarkdownTextRun[] { for (const [index, child] of (node.children ?? []).entries()) { if (index > 0) { - appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); + appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth, writingDirection }); } appendRun(runs, "│\u00a0", { ...EMPTY_CONTEXT, role: "quote-marker", depth, + writingDirection, }); if (child.type === "paragraph") { appendInlineChildren(runs, child, { ...EMPTY_CONTEXT, role: "body", depth, + writingDirection, }); } else { - appendDocumentBlock(runs, child, depth); + appendDocumentBlock(runs, child, depth, writingDirection); } } - appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); + appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth, writingDirection }); return runs; } @@ -726,6 +899,7 @@ function appendTableRow( ...EMPTY_CONTEXT, role: "divider", depth, + writingDirection: "ltr", }); } appendInlineChildren(runs, cell, { @@ -733,9 +907,10 @@ function appendTableRow( role: "body", bold: cell.isHeader ?? false, depth, + writingDirection: "ltr", }); } - appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); + appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth, writingDirection: "ltr" }); return runs; } @@ -761,6 +936,10 @@ function appendDocumentBlock( runs: NativeMarkdownTextRun[], node: MarkdownNode, depth = 0, + // Only the outermost block of a run resolves its own direction; nested blocks + // inherit it, so a list or quote reads as one directional unit (the web marks + // only the outermost block with `dir="auto"` for the same reason). + direction?: MarkdownWritingDirection, ): NativeMarkdownTextRun[] { switch (node.type) { case "document": { @@ -773,7 +952,7 @@ function appendDocumentBlock( child.type === "heading" ? 20 : previous?.type === "heading" ? 10 : 12, ); } - appendDocumentBlock(runs, child, depth); + appendDocumentBlock(runs, child, depth, direction); } return runs; } @@ -783,26 +962,35 @@ function appendDocumentBlock( role: "heading", headingLevel: node.level ?? 1, depth, + writingDirection: direction ?? markdownBlockDirection(node), }; appendInlineChildren(runs, node, context); return appendBlockTerminator(runs, context); } case "paragraph": { - const context: RunContext = { ...EMPTY_CONTEXT, role: "body", depth }; + const context: RunContext = { + ...EMPTY_CONTEXT, + role: "body", + depth, + writingDirection: direction ?? markdownBlockDirection(node), + }; appendInlineChildren(runs, node, context); return appendBlockTerminator(runs, context); } case "list": - return appendList(runs, node, depth + 1); + return appendList(runs, node, depth + 1, direction); case "blockquote": - return appendQuoteBlock(runs, node, depth); + return appendQuoteBlock(runs, node, depth, direction ?? markdownBlockDirection(node)); case "code_block": { + // Code stays LTR always: identifiers and paths read the same in every + // locale, and a Hebrew comment must not flip the snippet. if (node.language) { appendRun(runs, `${node.language.toUpperCase()}\n`, { ...EMPTY_CONTEXT, role: "code-language", code: true, depth, + writingDirection: "ltr", }); } const content = nodeTextContent(node); @@ -811,6 +999,7 @@ function appendDocumentBlock( role: "code-block", code: true, depth, + writingDirection: "ltr", }); if (!content.endsWith("\n")) { appendBlockTerminator(runs, { @@ -818,6 +1007,7 @@ function appendDocumentBlock( role: "code-block", code: true, depth, + writingDirection: "ltr", }); } return runs; @@ -831,19 +1021,36 @@ function appendDocumentBlock( return runs; case "table": return appendTable(runs, node, depth); - case "html_block": - appendRun(runs, inlineHtmlText(nodeTextContent(node)), { + case "html_block": { + const context: RunContext = { ...EMPTY_CONTEXT, role: "body", depth, - }); - return appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); - case "math_block": - appendRun(runs, nodeTextContent(node), { ...EMPTY_CONTEXT, role: "body", depth }); - return appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); - default: - appendInlineChildren(runs, node, { ...EMPTY_CONTEXT, role: "body", depth }); - return appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); + writingDirection: direction ?? markdownBlockDirection(node), + }; + appendRun(runs, inlineHtmlText(nodeTextContent(node)), context); + return appendBlockTerminator(runs, context); + } + case "math_block": { + const context: RunContext = { + ...EMPTY_CONTEXT, + role: "body", + depth, + writingDirection: direction ?? markdownBlockDirection(node), + }; + appendRun(runs, nodeTextContent(node), context); + return appendBlockTerminator(runs, context); + } + default: { + const context: RunContext = { + ...EMPTY_CONTEXT, + role: "body", + depth, + writingDirection: direction ?? markdownBlockDirection(node), + }; + appendInlineChildren(runs, node, context); + return appendBlockTerminator(runs, context); + } } } @@ -945,8 +1152,9 @@ export function nativeMarkdownChunkSpacing( export function nativeMarkdownDocumentRuns( node: MarkdownNode, skills: ReadonlyArray = [], + direction?: MarkdownWritingDirection, ): ReadonlyArray { - const runs = appendDocumentBlock([], node); + const runs = appendDocumentBlock([], node, 0, direction); while (runs.length > 0) { const lastIndex = runs.length - 1; const last = runs[lastIndex]; diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx index 80fe6be689bb..080f8078fed2 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -19,6 +19,16 @@ import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; import { ControlPill } from "../../components/ControlPill"; import { cn } from "../../lib/cn"; +import { resolvedTextDirection } from "@t3tools/mobile-markdown-text/markdown"; + +// The question and its options are agent-authored prose, so each string +// resolves its own direction — a Hebrew question reads and aligns +// right-to-left while the card chrome stays put. +function proseDirectionStyle(text: string) { + return resolvedTextDirection(text) === "rtl" + ? ({ writingDirection: "rtl", textAlign: "right" } as const) + : undefined; +} import { isPendingUserInputOptionSelected, type PendingUserInput, @@ -260,10 +270,16 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { const draft = props.drafts[question.id]; return ( - + {question.header} - + {question.question} @@ -289,6 +305,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { > {description ? ( - + {description} ) : null} diff --git a/apps/mobile/src/lib/nativeMarkdownText.test.ts b/apps/mobile/src/lib/nativeMarkdownText.test.ts index 3c4fa3ad0ae6..d9bfda7a0e41 100644 --- a/apps/mobile/src/lib/nativeMarkdownText.test.ts +++ b/apps/mobile/src/lib/nativeMarkdownText.test.ts @@ -273,15 +273,16 @@ describe("nativeMarkdownDocumentRuns", () => { ], }); expect(runs).toEqual([ - { text: "Inspect ", role: "body" }, + { text: "Inspect ", role: "body", writingDirection: "ltr" }, { text: "Checkout.tsx", role: "body", href: "src/Checkout.tsx", fileIcon: "react", sourceText: "@src/Checkout.tsx", + writingDirection: "ltr", }, - { text: ". Use @t3tools/contracts.", role: "body" }, + { text: ". Use @t3tools/contracts.", role: "body", writingDirection: "ltr" }, ]); }); @@ -319,14 +320,15 @@ describe("nativeMarkdownDocumentRuns", () => { }; expect(nativeMarkdownDocumentRuns(node, [{ name: "ui", displayName: "UI" }])).toEqual([ - { text: "Use ", role: "body" }, + { text: "Use ", role: "body", writingDirection: "ltr" }, { text: "$ui", role: "body", skillName: "ui", skillLabel: "UI", + writingDirection: "ltr", }, - { text: " for this.", role: "body" }, + { text: " for this.", role: "body", writingDirection: "ltr" }, ]); }); @@ -342,17 +344,52 @@ describe("nativeMarkdownDocumentRuns", () => { }; expect(nativeMarkdownDocumentRuns(node, [{ name: "2spec", displayName: "2Spec" }])).toEqual([ - { text: "Use ", role: "body" }, + { text: "Use ", role: "body", writingDirection: "ltr" }, { text: "$2spec", role: "body", skillName: "2spec", skillLabel: "2Spec", + writingDirection: "ltr", }, - { text: " for this.", role: "body" }, + { text: " for this.", role: "body", writingDirection: "ltr" }, ]); }); + it("keeps a skill reference intact when Latin runs are isolated for RTL text", () => { + const node: MarkdownNode = { + type: "document", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Use $ui for this." }], + }, + ], + }; + + const runs = nativeMarkdownDocumentRuns(node, [{ name: "ui", displayName: "UI" }], "rtl"); + const skillRun = runs.find((run) => run.skillName === "ui"); + expect(skillRun?.text).toBe("$ui"); + expect(skillRun?.skillLabel).toBe("UI"); + }); + + it("keeps a digit-led skill reference intact when Latin runs are isolated for RTL text", () => { + const node: MarkdownNode = { + type: "document", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Use $2spec for this." }], + }, + ], + }; + + const runs = nativeMarkdownDocumentRuns(node, [{ name: "2spec", displayName: "2Spec" }], "rtl"); + const skillRun = runs.find((run) => run.skillName === "2spec"); + expect(skillRun?.text).toBe("$2spec"); + expect(skillRun?.skillLabel).toBe("2Spec"); + }); + it("decorates known skill references inside blockquotes", () => { const node: MarkdownNode = { type: "blockquote", @@ -369,6 +406,7 @@ describe("nativeMarkdownDocumentRuns", () => { role: "body", skillName: "ui", skillLabel: "UI", + writingDirection: "ltr", }); }); @@ -384,7 +422,7 @@ describe("nativeMarkdownDocumentRuns", () => { }; expect(nativeMarkdownDocumentRuns(node, [])).toEqual([ - { text: "Use $unknown for this.", role: "body" }, + { text: "Use $unknown for this.", role: "body", writingDirection: "ltr" }, ]); }); @@ -440,11 +478,13 @@ describe("nativeMarkdownDocumentRuns", () => { text: "Header One\n", role: "heading", headingLevel: 1, + writingDirection: "ltr", }); expect(runs).toContainEqual({ text: "bold text", bold: true, role: "body", + writingDirection: "ltr", }); expect(runs).toContainEqual({ text: "•\t", @@ -453,6 +493,7 @@ describe("nativeMarkdownDocumentRuns", () => { firstLineHeadIndent: 0, headIndent: 24, paragraphSpacing: 2, + writingDirection: "ltr", }); }); @@ -519,11 +560,12 @@ describe("nativeMarkdownDocumentRuns", () => { firstLineHeadIndent: 0, headIndent: 24, paragraphSpacing: 2, + writingDirection: "ltr", }, - { text: "Finding:", bold: true, role: "body", depth: 1 }, - { text: " details with ", role: "body", depth: 1 }, - { text: "inline code", code: true, role: "body", depth: 1 }, - { text: ".", role: "body", depth: 1 }, + { text: "Finding:", bold: true, role: "body", depth: 1, writingDirection: "ltr" }, + { text: " details with ", role: "body", depth: 1, writingDirection: "ltr" }, + { text: "inline code", code: true, role: "body", depth: 1, writingDirection: "ltr" }, + { text: ".", role: "body", depth: 1, writingDirection: "ltr" }, ]); }); @@ -554,6 +596,7 @@ describe("nativeMarkdownDocumentRuns", () => { text: "const answer = 42;", code: true, role: "code-block", + writingDirection: "ltr", }); }); @@ -591,8 +634,8 @@ describe("nativeMarkdownDocumentRuns", () => { // Merging these would render one chip and emit one copy range with a // combined label for two distinct references. expect(runs).toEqual([ - { text: "First", role: "body", href, fileIcon: "bash" }, - { text: "Second", role: "body", href, fileIcon: "bash" }, + { text: "First", role: "body", href, fileIcon: "bash", writingDirection: "ltr" }, + { text: "Second", role: "body", href, fileIcon: "bash", writingDirection: "ltr" }, ]); }); }); diff --git a/apps/mobile/src/native/T3ComposerEditor.ios.tsx b/apps/mobile/src/native/T3ComposerEditor.ios.tsx index 30bc42f93e49..878034ea57ba 100644 --- a/apps/mobile/src/native/T3ComposerEditor.ios.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.ios.tsx @@ -21,6 +21,7 @@ import { contextChipPresentation, } from "@t3tools/mobile-markdown-text/markdown"; import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links"; +import { firstStrongDirection } from "@t3tools/mobile-markdown-text/markdown"; import { useUniwindTheme } from "../lib/useUniwindTheme"; import { flattenThemeColor } from "../lib/mobileTheme"; import { useFontFamily } from "../lib/useFontFamily"; @@ -78,6 +79,7 @@ interface NativeComposerEditorProps extends ViewProps { readonly fontSize: number; readonly lineHeight: number; readonly contentInsetVertical: number; + readonly writingDirection: "ltr" | "rtl"; readonly editable: boolean; readonly readOnly: boolean; readonly enterBehavior: string; @@ -291,6 +293,10 @@ export function ComposerEditor({ : bodyText.lineHeight } contentInsetVertical={contentInsetVertical} + // Live composer direction: the draft's first strong letter decides + // (plain first-strong, no tech-token stripping — while typing, follow + // what the user actually typed; empty resets to LTR). + writingDirection={firstStrongDirection(props.value)} editable={props.editable ?? true} readOnly={props.readOnly ?? false} enterBehavior={props.enterBehavior ?? DEFAULT_COMPOSER_ENTER_BEHAVIOR} diff --git a/apps/mobile/src/native/T3ComposerEditor.tsx b/apps/mobile/src/native/T3ComposerEditor.tsx index 22cdf3b53106..167e807da1fb 100644 --- a/apps/mobile/src/native/T3ComposerEditor.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.tsx @@ -2,6 +2,7 @@ import { TextInputWrapper } from "expo-paste-input"; import { useImperativeHandle, useRef } from "react"; import { TextInput, type TextInput as RNTextInput } from "react-native"; +import { firstStrongDirection } from "@t3tools/mobile-markdown-text/markdown"; import { useFontFamily } from "../lib/useFontFamily"; import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; import { useNativePaste } from "../lib/useNativePaste"; @@ -25,6 +26,10 @@ export function ComposerEditor({ const bodyText = useScaledTextRole("body"); const fontFamily = useFontFamily("regular"); const handlePaste = useNativePaste((uris) => onPasteImages?.(uris)); + // Live composer direction: the draft's first strong letter decides (plain + // first-strong — while typing, follow what the user actually typed; empty + // resets to LTR). `writingDirection` is iOS-only; `textAlign` covers both. + const writingDirection = firstStrongDirection(props.value); useImperativeHandle( ref, @@ -57,6 +62,12 @@ export function ComposerEditor({ paddingVertical: contentInsetVertical, }, textStyle, + // Direction is the draft's, not the caller's — kept last so it can't + // be overridden by a `textAlign`/`writingDirection` in `textStyle`. + { + textAlign: writingDirection === "rtl" ? "right" : "left", + writingDirection, + }, ]} /> diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index 4e22e68ca902..6621ad06bf64 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -60,7 +60,9 @@ vi.mock("~/lib/openPullRequestLink", () => ({ import ChatMarkdown, { canUseMarkdownFileShellActions, + firstStrongDirection, hasMarkdownFilePrimaryAction, + resolvedTextDirection, shouldUseMarkdownFileBrowserPrimaryAction, } from "./ChatMarkdown"; @@ -614,8 +616,10 @@ describe("ChatMarkdown file option chips", () => { ); const nestedLinkText = nestedLinkHtml.replace(/<[^>]+>/g, ""); + // The list item carries dir="auto" like every other bidi leaf block; what + // this asserts is that the rejected citation survives verbatim inside it. expect(malformedHtml).toContain( - "
  • Bad :codex-file-citation{purpose="output"}
  • ", + '
  • Bad :codex-file-citation{purpose="output"}
  • ', ); expect(nestedLinkText).toContain( "Bad :codex-file-citation{path="/tmp/project/report.xlsx"}", @@ -704,15 +708,15 @@ describe("ChatMarkdown heading levels", () => { />, ); - expect(html).toContain('

    Top

    '); - expect(html).toContain('

    Section

    '); - expect(html).toContain('
    Fine print
    '); + expect(html).toContain('

    Top

    '); + expect(html).toContain('

    Section

    '); + expect(html).toContain('
    Fine print
    '); }); it("leaves heading levels alone when the markdown is not nested", () => { const html = renderToStaticMarkup(); - expect(html).toContain("

    Top

    "); + expect(html).toContain('

    Top

    '); }); }); @@ -860,3 +864,205 @@ describe("ChatMarkdown Windows file links", () => { expect(html).not.toContain("chat-markdown-file-link"); }); }); + +describe("chat markdown text direction", () => { + function render(text: string) { + return renderToStaticMarkup(); + } + + it("lets each block pick its own direction from its own text", () => { + const html = render("English first.\n\nمرحبا بالعالم."); + expect(html).toContain('

    English first.

    '); + expect(html).toContain('

    مرحبا بالعالم.

    '); + }); + + it("marks headings, lists, and quotes so their markers follow the text", () => { + const html = render("# عنوان\n\n- عنصر\n\n> اقتباس"); + expect(html).toContain('

    '); + // The list's gutter side is pinned from all its items together. + expect(html).toContain('
      '); + expect(html).toContain('
      '); + }); + + it("gives every list item its own direction, so mixed lists keep each marker beside its text", () => { + const html = render("- English item\n- פריט בעברית"); + expect(html).toContain('
        '); + expect(html).toContain('
      • '); + }); + + it("gives a nested list's items their own direction too, not just the top level", () => { + // A Hebrew item nested under an English top-level item must still get its + // own `dir`, or its marker inherits the (wrong) English sub-list side. + const html = render("- English top\n - English sub\n - פריט בעברית"); + expect(html).toContain('
      • פריט בעברית
      • '); + }); + + it("does not re-mark the blocks inside a claimed quote", () => { + const html = render("> اقتباس"); + // `renderToStaticMarkup` serializes adjacent tags with no separator, so + // this is the actual boundary a nested, wrongly re-marked paragraph + // would produce — the newline-separated form the assertion used to check + // for can never appear in real output. + expect(html).not.toContain('

        '); + }); + + it("pins code left-to-right so an Arabic comment cannot reorder a snippet", () => { + const html = render("`git status` وأيضا\n\n```sh\n# تعليق\ngit status\n```"); + // The paragraph around it still reads right-to-left; only the code opts out. + expect(html).toContain('

        '); + expect(html).toContain('git status'); + expect(html).toContain('

        { + // The alert renderer builds its own element, so the blockquote cannot be the + // marked block — the body paragraphs have to carry the direction instead. + const html = render("> [!NOTE]\n> مرحبا بالعالم."); + expect(html).toContain('

        مرحبا بالعالم.

        '); + expect(html).not.toContain(" { + // The `code` renderer swaps the chip in for the `` it + // replaces, so a path in an Arabic sentence keeps its own reading order. + const html = render("عدّل `src/main.ts` من فضلك."); + // The chip renders as an anchor or, with no primary action, a button — + // either way it carries the LTR pin. + expect(html).toMatch(/<(a|button)[^>]* dir="ltr"/); + }); + + it("gives a table its base direction from its own content, cells still self-resolve", () => { + // The direction sits on the scroll viewport wrapping the table, so an + // overflowing Hebrew/Arabic table opens at its first, rightmost column. + const html = render("| اسم | value |\n| --- | --- |\n| قيمة | 1 |"); + expect(html).toContain('dir="rtl"'); + expect(html).toContain(''); + expect(html).toContain(''); + }); + + it("keeps an English table left-to-right", () => { + const html = render("| Name | value |\n| --- | --- |\n| a | 1 |"); + expect(html).not.toContain('dir="rtl"'); + }); + + it('keeps a Hebrew block opening with an inline-code span on dir="auto"', () => { + // The code span carries its own dir="ltr", so both the plugin's detection + // text and the browser's dir="auto" scan skip it — no pin needed. + const html = render("`server.py` זה הקובץ הראשי"); + expect(html).toContain('

        '); + expect(html).not.toContain('

        '); + }); + + it("pins a Hebrew block that opens with a URL right-to-left", () => { + const html = render("https://claude.ai זה האתר של קלוד"); + expect(html).toContain('

        '); + }); + + it("pins a Hebrew block that opens with a path right-to-left", () => { + const html = render("src/main.ts זה הקובץ שצריך לערוך"); + expect(html).toContain('

        '); + }); + + it("pins a Hebrew list that opens with a tech token right-to-left, markers included", () => { + const html = render("- server.py זה הקובץ\n- עוד פריט"); + expect(html).toContain('

          '); + }); + + it("keeps an English block with one Hebrew word on the browser's own resolution", () => { + const html = render("The word שלום means hello"); + expect(html).toContain('

          '); + expect(html).not.toContain('dir="rtl"'); + }); + + it("keeps a pure English block on the browser's own resolution", () => { + const html = render("English only, no tech tokens."); + expect(html).toContain('

          '); + expect(html).not.toContain('dir="rtl"'); + }); + + it('leaves a Hebrew-first block on dir="auto", unchanged', () => { + const html = render("שלום, תריץ `git status` עכשיו"); + expect(html).toContain('

          '); + expect(html).not.toContain('

          '); + }); + + it("isolates a Latin run inside RTL prose so its quotes stay on the right sides", () => { + const html = render('הבוט "סותר את Kapso" לגמרי'); + expect(html).toContain("Kapso"); + }); + + it("keeps a compound Latin run whole inside one isolate", () => { + const html = render("דמו = U1+U2+U3+U5, ההסלמה אחרי"); + expect(html).toContain("U1+U2+U3+U5"); + }); + + it("leaves English blocks and code untouched by the isolation pass", () => { + const html = render("Plain English `code span` here"); + expect(html).not.toContain(""); + const rtlWithCode = render("תריץ `git status` עכשיו"); + expect(rtlWithCode).toContain('git status'); + }); + + it("keeps a link atomic inside RTL prose instead of slicing it into isolates", () => { + const html = render("הקישור https://claude.ai/docs זה טוב"); + expect(html).not.toContain("https"); + }); + + it("gives a table opening with a tech-token cell its direction from its prose", () => { + const html = render("| `id.ts` | שם |\n| --- | --- |\n| `a.py` | קובץ |"); + expect(html).toContain('dir="rtl"'); + }); +}); + +describe("resolvedTextDirection", () => { + it("discounts leading tech tokens when the text is RTL prose", () => { + expect(resolvedTextDirection("https://claude.ai זה האתר של קלוד")).toBe("rtl"); + expect(resolvedTextDirection("server.py זה הקובץ הראשי")).toBe("rtl"); + expect(resolvedTextDirection("src/main.ts זה הקובץ")).toBe("rtl"); + expect(resolvedTextDirection("`git status` תריץ קודם")).toBe("rtl"); + }); + + it("keeps English text left-to-right, one Hebrew word or none", () => { + expect(resolvedTextDirection("The word שלום means hello")).toBe("ltr"); + expect(resolvedTextDirection("Hello world")).toBe("ltr"); + // Latin letters hold the majority here, so the leading English words decide. + expect(resolvedTextDirection("Claude Code זה כלי")).toBe("ltr"); + }); + + it("reads a Hebrew sentence that opens with a Latin prose label right-to-left", () => { + expect( + resolvedTextDirection('Next step (ישן): "מתחילים לבנות תחנה 1, לאט. החוסמים: 3 קבצים."'), + ).toBe("rtl"); + expect(resolvedTextDirection("TL;DR: הפיצ׳ר עובד, נשאר רק לנקות את הקוד")).toBe("rtl"); + // A Latin-majority sentence quoting some Hebrew still reads left-to-right. + expect(resolvedTextDirection("The customer wrote שלום וברכה in the ticket")).toBe("ltr"); + }); + + it("discounts quoted and parenthesized Latin citations from the vote", () => { + expect(resolvedTextDirection('PROFILE — הוספתי סעיף "Build-feedback call additions"')).toBe( + "rtl", + ); + expect(resolvedTextDirection("P1 — אסטרטגיות (product-lens):")).toBe("rtl"); + // A Hebrew quotation inside English prose keeps its vote — still LTR. + expect(resolvedTextDirection('They titled it "ברוכים הבאים" and moved on quickly')).toBe("ltr"); + }); + + it("keeps Hebrew-first text right-to-left, unchanged", () => { + expect(resolvedTextDirection("שלום, זה טקסט עם Claude Code בתוכו")).toBe("rtl"); + }); +}); + +describe("firstStrongDirection", () => { + it("reads the first letter, skipping neutral digits and punctuation", () => { + expect(firstStrongDirection("רכיב | סטטוס")).toBe("rtl"); + expect(firstStrongDirection("1. (שלב) ראשון")).toBe("rtl"); + expect(firstStrongDirection("\u{1E900}\u{1E92F} adlam")).toBe("rtl"); // astral RTL block + expect(firstStrongDirection("Component | Status")).toBe("ltr"); + expect(firstStrongDirection("42 — Next.js then עברית")).toBe("ltr"); + }); + + it("falls back to ltr when there is no strong character", () => { + expect(firstStrongDirection("")).toBe("ltr"); + expect(firstStrongDirection("123 | 456")).toBe("ltr"); + }); +}); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index b0209754b0d0..a931e2771524 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1,3 +1,4 @@ +import { DirectionProvider, type TextDirection } from "@base-ui/react/direction-provider"; import { usePullRequestLinking } from "~/hooks/usePullRequestLinking"; import { useAtomValue } from "@effect/atom-react"; import { @@ -463,7 +464,11 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { ...defaultSchema.attributes, "*": (defaultSchema.attributes?.["*"] ?? []).filter((attribute) => attribute !== "title"), code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta", "dataInlineCode"], - blockquote: [...(defaultSchema.attributes?.blockquote ?? []), "dataAlert"], + blockquote: [ + ...(defaultSchema.attributes?.blockquote ?? []), + "dataAlert", + "dataAlertDirection", + ], div: [...(defaultSchema.attributes?.div ?? []), ...CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES], a: [...(defaultSchema.attributes?.a ?? []), "dataPullRequestAutolink"], img: [ @@ -487,6 +492,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS = [ remarkCodexDirectives, remarkPreserveCodeMeta, remarkNormalizeLinksAndTagInlineCode, + remarkTextDirection, ] satisfies NonNullable; const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ @@ -497,12 +503,109 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ remarkBreaks, remarkPreserveCodeMeta, remarkNormalizeLinksAndTagInlineCode, + remarkTextDirection, ] satisfies NonNullable; +// Inside a right-to-left block the bidi algorithm hands the neutrals around a +// Latin run — quotes, commas, a plus sign — to whichever strong run is nearer, +// which strands them on the wrong visual side ('"AIOS" סותר' flips its quotes, +// "U1+U2+U3, ההסלמה" splits the comma off its run). Wrapping each Latin run in +// a isolates it, so the punctuation around it resolves against the +// Hebrew it belongs to. A run may span several words joined by thin neutrals +// ("speed-to-lead", "U1+U2+U3+U5", "OpenAI export"); a connector is only +// swallowed when another Latin word follows it, so sentence-final punctuation +// stays outside the isolate. +const LATIN_RUN = /\p{Script=Latin}[\p{Script=Latin}\d]*(?:[ +&/.:'@_-]+[\p{Script=Latin}\d]+)*/gu; +const BIDI_LEAF_TAG_NAMES = new Set([ + "p", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "td", + "th", + "li", + "dt", + "dd", +]); +// Links stay atomic: an anchor's text is usually a URL or a title whose own +// strong letters already resolve as one run — slicing it into isolates would +// let the neutrals between the pieces reorder against the paragraph. +const BIDI_SKIP_TAG_NAMES = new Set(["a", "code", "pre", "bdi", "bdo"]); + +type HastNode = { + type?: string; + tagName?: string; + value?: string; + properties?: Record; + children?: HastNode[]; +}; + +function splitTextIntoIsolates(value: string): HastNode[] { + const out: HastNode[] = []; + let last = 0; + for (const match of value.matchAll(LATIN_RUN)) { + const start = match.index ?? 0; + if (start > last) { + out.push({ type: "text", value: value.slice(last, start) }); + } + out.push({ + type: "element", + tagName: "bdi", + properties: {}, + children: [{ type: "text", value: match[0] }], + }); + last = start + match[0].length; + } + if (last === 0) { + return [{ type: "text", value }]; + } + if (last < value.length) { + out.push({ type: "text", value: value.slice(last) }); + } + return out; +} + +function isolateLatinRuns(node: HastNode) { + if (!node.children) { + return; + } + node.children = node.children.flatMap((child): HastNode[] => { + if (child.type === "text" && typeof child.value === "string") { + return splitTextIntoIsolates(child.value); + } + if (child.type === "element" && BIDI_SKIP_TAG_NAMES.has(child.tagName ?? "")) { + return [child]; + } + isolateLatinRuns(child); + return [child]; + }); +} + +function rehypeIsolateLatinRuns() { + return (tree: HastNode) => { + const visit = (node: HastNode) => { + if ( + node.type === "element" && + BIDI_LEAF_TAG_NAMES.has(node.tagName ?? "") && + resolvedTextDirection(hastTextContent(node)) === "rtl" + ) { + isolateLatinRuns(node); + return; + } + node.children?.forEach(visit); + }; + visit(tree); + }; +} + const CHAT_MARKDOWN_REHYPE_PLUGINS = [ rehypeRaw, rehypePreserveImageSourceMeta, [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA], + rehypeIsolateLatinRuns, ] satisfies NonNullable; /** GitHub's own five alert kinds, in its colors: the glyph names the urgency, the title says it. */ @@ -582,6 +685,7 @@ function extractPreCodeMeta(node: unknown): string | undefined { type MarkdownAstNode = { type?: string; meta?: unknown; + value?: string; url?: string; data?: { hProperties?: Record; @@ -640,6 +744,151 @@ function remarkNormalizeLinksAndTagInlineCode() { }; } +/** + * Message prose belongs to whoever wrote it, so its direction is a property of + * the text and not of the app: `dir="auto"` makes the browser read each block's + * base direction off that block's own first strong character, which is what + * puts an Arabic sentence's trailing punctuation and its list markers on the + * right side without touching the English block above it. + * + * Code and tables opt out and stay LTR. Their shape is not prose — identifiers, + * paths, and column order read the same in every locale, and letting an Arabic + * comment flip a snippet would misreport what the agent actually wrote. + */ +const AUTO_DIRECTION_NODE_TYPES = new Set([ + "blockquote", + "heading", + "listItem", + "paragraph", + "tableCell", +]); +const LTR_DIRECTION_NODE_TYPES = new Set(["code", "inlineCode", "table"]); + +/** + * The text a block's direction is judged from: its own prose, with the + * LTR-pinned nodes (inline code, fences, tables) excluded structurally — + * `dir="auto"` skips them too, since they carry their own `dir`. + */ +function directionDetectionText(node: MarkdownAstNode): string { + if (LTR_DIRECTION_NODE_TYPES.has(node.type ?? "")) { + return ""; + } + if (typeof node.value === "string") { + return node.value; + } + return (node.children ?? []).map(directionDetectionText).join(""); +} + +function setDirection(node: MarkdownAstNode, dir: "auto" | "ltr" | "rtl") { + node.data = { + ...node.data, + hProperties: { + ...node.data?.hProperties, + dir, + }, + }; +} + +function remarkTextDirection() { + return (tree: MarkdownAstNode) => { + // `dir="auto"` reads the first strong character of an element's *own* text + // and skips any descendant that carries its own `dir`. Blocks below a marked + // one are normally left alone (the `plaintext` CSS lets each resolve its own + // text), with two exceptions: every list item is marked so a Hebrew item in + // an English list still gets its bullet in the right-hand gutter (a marker's + // side follows the `direction` property, which only a `dir` attribute + // flips), and a leaf whose heuristic disagrees with its own first-strong + // scan is pinned, since `plaintext` cannot discount a leading Latin token. + // `pinnedRtl` tracks whether the nearest claimed ancestor was forced to an + // explicit `dir="rtl"` (a heuristic override, not the default `"auto"`). + // That pin is deliberate — the `[dir="rtl"] … li` CSS rule means nested + // content should inherit it rather than recompute its own direction. + // Everywhere else — including under a plain `"auto"` ancestor — a nested + // list's own items still resolve independently, same as a top-level one. + const visit = (node: MarkdownAstNode, insideAutoBlock: boolean, pinnedRtl: boolean) => { + const type = node.type ?? ""; + if (LTR_DIRECTION_NODE_TYPES.has(type)) { + setDirection(node, "ltr"); + // A pinned table is not an `auto` ancestor, so its cells are free to + // pick their own direction while the column order stays put. + node.children?.forEach((child) => visit(child, false, false)); + return; + } + + if (type === "list") { + // Each item claims its own direction below, which leaves the list + // element itself no text for `dir="auto"` to judge — so its gutter + // side is pinned explicitly from all the items together. + if (!insideAutoBlock) { + setDirection(node, resolvedTextDirection(directionDetectionText(node))); + } + // A nested list's items resolve from their own text just like a + // top-level list's, unless they sit under a pinned `dir="rtl"` + // ancestor — there, inheritance is the point, so leave them be. + node.children?.forEach((child) => visit(child, pinnedRtl && insideAutoBlock, pinnedRtl)); + return; + } + + // A GitHub alert is rendered as a titled callout rather than a quote, and + // its own renderer builds that chrome from scratch. Its outer container + // hardcodes `dir="auto"`, but the browser's native scan for that skips + // any descendant that itself carries an explicit `dir` — and the alert's + // own paragraphs need one (below) so their own text aligns correctly. + // That leaves the container's native scan nothing to resolve from, so + // its direction is computed here instead and threaded through as data + // for the renderer to apply directly. + const isAlertBlockquote = type === "blockquote" && node.data?.hProperties?.dataAlert != null; + if (isAlertBlockquote) { + node.data = { + ...node.data, + hProperties: { + ...node.data?.hProperties, + dataAlertDirection: resolvedTextDirection(directionDetectionText(node)), + }, + }; + } + const isDirectionBlock = !isAlertBlockquote && AUTO_DIRECTION_NODE_TYPES.has(type); + let pinnedRtlHere = false; + if (isDirectionBlock && !insideAutoBlock) { + // `dir="auto"` (and the `plaintext` CSS) is the browser's own first-strong + // scan, which cannot discount a leading Latin tech token — "server.py זה + // הקובץ" resolves LTR. When the heuristic disagrees with plain first-strong, + // pin the block with an explicit `dir="rtl"` (index.css lifts `plaintext` + // for it); everywhere else the browser keeps resolving the block itself. + const detectionText = directionDetectionText(node); + const dir = + firstStrongDirection(detectionText) === "ltr" && + resolvedTextDirection(detectionText) === "rtl" + ? "rtl" + : "auto"; + setDirection(node, dir); + pinnedRtlHere = dir === "rtl"; + } else if (isDirectionBlock && insideAutoBlock) { + // Inside a claimed block the `plaintext` CSS still re-resolves each + // leaf from its own text — pin just the leaves whose leading Latin + // token would make that scan misread otherwise-RTL prose. + const detectionText = directionDetectionText(node); + if ( + firstStrongDirection(detectionText) === "ltr" && + resolvedTextDirection(detectionText) === "rtl" + ) { + setDirection(node, "rtl"); + pinnedRtlHere = true; + } + } + node.children?.forEach((child) => + visit( + child, + insideAutoBlock || (isDirectionBlock && !insideAutoBlock), + pinnedRtl || pinnedRtlHere, + ), + ); + }; + + visit(tree, false, false); + }; +} + function nodeToPlainText(node: ReactNode): string { if (typeof node === "string" || typeof node === "number") { return String(node); @@ -693,7 +942,84 @@ function readInitialWordWrapSetting(): boolean { return getClientSettings().wordWrap; } -function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { +// Strong-RTL code points: Hebrew, Arabic, Syriac, Thaana, NKo, Samaritan, Mandaic and +// their extensions/presentation forms, the RTL formatting mark (RLM), plus the astral +// RTL blocks (Phoenician … Adlam). +const STRONG_RTL_CHAR = + /[\u0590-\u08FF\u200F\uFB1D-\uFDFF\uFE70-\uFEFF\u{10800}-\u{10FFF}\u{1E800}-\u{1EFFF}]/u; +// First letter decides (UBA P2/P3): digits, punctuation and symbols are neutral. +// RLM/ALM (the strong-direction formatting marks) count too — a block that opens +// with one is asserting its direction explicitly. +const FIRST_LETTER = /[\p{L}\u061C\u200F]/u; + +// The direction a block of text renders in — what `dir="auto"` would resolve. +export function firstStrongDirection(text: string): TextDirection { + const letter = FIRST_LETTER.exec(text)?.[0]; + return letter && STRONG_RTL_CHAR.test(letter) ? "rtl" : "ltr"; +} + +// The Latin spans that must not get the direction vote: tech tokens a Hebrew +// sentence often *opens* with (a URL, an inline-code span, a path, a file name +// — "server.py זה הקובץ הראשי"), plus quoted or parenthesized Latin — a cited +// title or gloss ('הוספתי סעיף "Build-feedback call additions"', "אסטרטגיות +// (product-lens)") names a thing rather than continuing the prose. Mirrors the +// mobile app's pattern (each app keeps its own copy — no cross-app imports) +// and stripLeadingLTR from the claude-desktop-rtl-patch. +const LTR_TECH_TOKEN = + /https?:\/\/\S+|`[^`\n]+`|(?:^|(?<=\s))\S*[/\\]\S+|\b\w+\.\w{1,5}\b|"[^"\n]+"|[“«][^”»\n]+[”»]|\([^()\n]+\)/gu; + +function stripLtrTechTokens(text: string): string { + // A span carrying its own strong-RTL letters (an RTL slash pair like כן/לא, + // a Hebrew quotation) is prose, not a citation — it keeps its vote. + return text.replace(LTR_TECH_TOKEN, (token) => (STRONG_RTL_CHAR.test(token) ? token : " ")); +} + +// The last-resort vote: which strong script owns most of the text's letters. +// Counted per letter (`\p{L}`), so neutral digits/punctuation and RTL combining +// marks (niqqud, harakat — marks, not letters) never tilt the tally. +function rtlLetterMajority(text: string): boolean { + let balance = 0; + for (const letter of text.match(/\p{L}/gu) ?? []) { + balance += STRONG_RTL_CHAR.test(letter) ? 1 : -1; + } + return balance > 0; +} + +// First-strong, with two corrections for RTL prose that *opens* with Latin: +// a leading tech token (URL, path, file name) never gets the first-strong vote, +// and a text whose letters are mostly RTL is RTL even when it leads with a +// Latin prose label — "**Next step (ישן):** מתחילים לבנות…" is a Hebrew +// sentence, and reading it LTR strands its closing punctuation on the wrong +// side. A mostly-English text with a few Hebrew words stays LTR — its Latin +// letters keep the majority. +export function resolvedTextDirection(text: string): TextDirection { + if (firstStrongDirection(text) === "rtl") { + return "rtl"; + } + if (!STRONG_RTL_CHAR.test(text)) { + return "ltr"; + } + const stripped = stripLtrTechTokens(text); + if (firstStrongDirection(stripped) === "rtl") { + return "rtl"; + } + return rtlLetterMajority(stripped) ? "rtl" : "ltr"; +} + +function hastTextContent(node: unknown): string { + if (!node || typeof node !== "object") return ""; + const n = node as { type?: string; tagName?: string; value?: string; children?: unknown[] }; + if (n.type === "text") return n.value ?? ""; + // Code is direction-neutral here too, mirroring the mdast-side exclusion. + if (n.tagName === "code") return ""; + return (n.children ?? []).map(hastTextContent).join(""); +} + +function MarkdownTable({ + children, + dir = "ltr", + ...props +}: Omit, "dir"> & { dir?: TextDirection }) { const containerRef = useRef(null); const tableRef = useRef(null); const [expanded, setExpanded] = useState(readInitialWordWrapSetting); @@ -768,11 +1094,22 @@ function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { className="chat-markdown-table-container" data-expanded={expanded ? "true" : "false"} > - - - {children} -
          -
          + {/* A concrete direction on the scroll viewport (not just the table) so an + overflowing RTL table opens at its first, rightmost column — and the same + value fed to Base UI, whose scroll-fade math reads its DirectionProvider + rather than the DOM `dir`. */} + + + + {children} +
          +
          +

          ` it replaces, so the pin has to live here too. + dir="ltr" href={href} className={cn( CHAT_FILE_TAG_CHIP_CLASS_NAME, @@ -2155,6 +2499,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ ) : (
          {comment.text.length > 0 && ( -
          +
          )} diff --git a/apps/web/src/components/ui/scroll-area.tsx b/apps/web/src/components/ui/scroll-area.tsx index 852a3ed10053..af7ec02ce79b 100644 --- a/apps/web/src/components/ui/scroll-area.tsx +++ b/apps/web/src/components/ui/scroll-area.tsx @@ -50,6 +50,10 @@ function ScrollArea({ chainVerticalScroll && "overscroll-y-auto", scrollFade && "mask-t-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-start)))] mask-b-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-end)))] mask-l-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-start)))] mask-r-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-end)))] [--fade-size:1.5rem]", + // Base UI's overflow-x vars are logical (start = the scroll-start edge), while the + // mask utilities are physical — under dir="rtl" the start edge is the right one. + scrollFade && + "rtl:mask-l-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-end)))] rtl:mask-r-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-start)))]", scrollFade && scrollFadePadding && "scroll-p-[var(--fade-size)]", scrollbarGutter && "scrollbar-gutter-stable", hideScrollbars && diff --git a/apps/web/src/index.css b/apps/web/src/index.css index e7c5d919a211..9df695f374c2 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1657,6 +1657,34 @@ code { /* Chat markdown rendering */ +/* Bidi: Hebrew/Arabic messages. Every leaf block resolves its own base direction + from its first strong character (`plaintext`), so a Hebrew paragraph reads and + aligns right-to-left while the English block above it stays put — no global + flip, mixed-language threads just work. Containers that carry a directional + decoration (list markers, blockquote bar, table column order) get `dir="auto"` + in ChatMarkdown.tsx and use logical properties below. Code stays LTR. */ +.chat-markdown :is(p, li, h1, h2, h3, h4, h5, h6, td, th, dt, dd) { + unicode-bidi: plaintext; +} + +/* A block ChatMarkdown.tsx pinned `dir="rtl"`: RTL prose that *opens* with a + Latin tech token (a URL, a path, `server.py`), which the browser's own + first-strong scan would misread as LTR. `plaintext` ignores the `dir` + attribute, so the pinned block — and the plaintext leaves inside a pinned + list or quote, which would otherwise re-resolve themselves line by line — + falls back to `isolate` and inherits the pinned direction. */ +.chat-markdown [dir="rtl"], +.chat-markdown [dir="rtl"] :is(p, li, h1, h2, h3, h4, h5, h6, td, th, dt, dd) { + unicode-bidi: isolate; +} + +.chat-markdown pre, +.chat-markdown code, +.chat-markdown .chat-markdown-codeblock { + direction: ltr; + unicode-bidi: isolate; +} + .chat-markdown > :first-child { margin-top: 0; } @@ -1713,7 +1741,9 @@ code { custom property, so without this a task-list under a multi-digit ordered list would inherit the outer gutter instead of its own default. */ --list-gutter: 1.25rem; - padding-left: 1.25rem; + /* Logical, because a list whose text is Arabic or Hebrew carries dir="auto" + and paints its markers on the right — the gutter has to move with them. */ + padding-inline-start: 1.25rem; list-style-type: disc; } @@ -1724,7 +1754,7 @@ code { nested ol without its own widened marker doesn't inherit the outer one. */ .chat-markdown ol { --list-gutter: 1.25rem; - padding-left: var(--list-gutter, 1.25rem); + padding-inline-start: var(--list-gutter, 1.25rem); list-style-type: decimal; } @@ -1758,7 +1788,8 @@ code { } .chat-markdown li.task-list-item input[type="checkbox"] { - margin: 0 0.35em 0.15em calc(-1 * var(--list-gutter, 1.25rem)); + margin-block: 0 0.15em; + margin-inline: calc(-1 * var(--list-gutter, 1.25rem)) 0.35em; vertical-align: middle; } @@ -1782,8 +1813,9 @@ code { } .chat-markdown blockquote { - border-left: 2px solid var(--contrast-border); - padding-left: 0.8rem; + /* Logical, so an RTL quote's bar sits on its right. */ + border-inline-start: 2px solid var(--contrast-border); + padding-inline-start: 0.8rem; color: var(--contrast-muted-foreground); } @@ -1797,6 +1829,7 @@ code { .chat-markdown section[data-footnotes] ol { margin: 0; + padding-inline-start: 1.25rem; } .chat-markdown section[data-footnotes] li + li { @@ -1924,7 +1957,10 @@ code { .chat-markdown th, .chat-markdown td { padding: 0.45rem 0.75rem; - text-align: left; + /* Logical: the table's base direction comes from its own content (an RTL + table opens at its rightmost column), and each cell carries dir="auto" + so it aligns to its own text. */ + text-align: start; } .chat-markdown thead th {