diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift
index 96551d05bbb55..a0c9549ca38dd 100644
--- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift
+++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift
@@ -24,9 +24,16 @@ private struct ComposerControlledDocumentPayload: Decodable {
let tokensJson: String
let mostRecentEventCount: Int
let isNativeEcho: Bool
+ let paragraphDirections: [ComposerParagraphDirectionPayload]?
let resetGeneration: Int
}
+private struct ComposerParagraphDirectionPayload: Decodable {
+ let start: Int
+ let end: Int
+ let direction: String
+}
+
private struct ComposerThemePayload: Decodable {
let text: String
let placeholder: String
@@ -453,6 +460,8 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
private var value = ""
private var tokensJson = "[]"
private var tokens: [ComposerTokenPayload] = []
+ private var paragraphDirections: [ComposerParagraphDirectionPayload] = []
+ private var paragraphDirectionValue = ""
private var requestedSelection: ComposerSelectionPayload?
private var theme = ComposerThemePayload(
text: "#262626",
@@ -719,6 +728,8 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
}
let previousSelection = sourceSelection()
value = document.value
+ paragraphDirections = document.paragraphDirections ?? []
+ paragraphDirectionValue = document.value
requestedSelection = document.selection
applyControlledDocument(force: tokensNeedRebuild)
applyRequestedSelection()
@@ -940,6 +951,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
let shouldForce = force || hasDeferredForcedRebuild
let currentSource = textView.serializedText()
guard shouldForce || currentSource != value || !documentMatchesExpectedTokens() else {
+ applyParagraphDirections()
updatePlaceholderVisibility()
return
}
@@ -955,6 +967,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
requestedSelection = nil
textView.selectedRange = displayRange(for: targetSelection)
restoreBaseTypingAttributes()
+ applyParagraphDirections()
isApplyingControlledValue = false
if currentSource != value || previousSelection.start != targetSelection.start || previousSelection.end != targetSelection.end {
revealSelectionAfterLayout()
@@ -1140,19 +1153,57 @@ 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 paragraphStyle(direction: String? = nil) -> NSMutableParagraphStyle {
let paragraph = NSMutableParagraphStyle()
+ if let direction {
+ paragraph.alignment = direction == "rtl" ? .right : .left
+ paragraph.baseWritingDirection = direction == "rtl" ? .rightToLeft : .leftToRight
+ }
paragraph.minimumLineHeight = lineHeight
paragraph.maximumLineHeight = lineHeight
+ 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: paragraphStyle(),
]
}
+ private func applyParagraphDirections() {
+ guard !paragraphDirections.isEmpty, textView.markedTextRange == nil,
+ textView.serializedText() == paragraphDirectionValue else { return }
+ let wasApplyingControlledValue = isApplyingControlledValue
+ isApplyingControlledValue = true
+ let selection = textView.selectedRange
+ let sourceLength = (paragraphDirectionValue as NSString).length
+ textView.textStorage.beginEditing()
+ for paragraph in paragraphDirections {
+ guard paragraph.start >= 0, paragraph.end >= paragraph.start,
+ paragraph.end <= sourceLength else { continue }
+ let range = displayRange(for: ComposerSelectionPayload(start: paragraph.start, end: paragraph.end))
+ guard range.length > 0 else { continue }
+ let style = paragraphStyle(direction: paragraph.direction)
+ let current = textView.textStorage.attribute(.paragraphStyle, at: range.location, effectiveRange: nil) as? NSParagraphStyle
+ if current != style {
+ textView.textStorage.addAttribute(.paragraphStyle, value: style, range: range)
+ }
+ }
+ textView.textStorage.endEditing()
+ if textView.selectedRange != selection { textView.selectedRange = selection }
+ let sourceCaret = textView.sourceOffset(forDisplayOffset: selection.location)
+ if let paragraph = paragraphDirections.last(where: { $0.start <= sourceCaret }) {
+ var attributes = baseAttributes()
+ attributes[.paragraphStyle] = paragraphStyle(direction: paragraph.direction)
+ textView.typingAttributes = attributes
+ }
+ isApplyingControlledValue = wasApplyingControlledValue
+ }
+
private func applyTypography() {
let font = UIFont(name: fontFamily, size: fontSize)
?? UIFont.systemFont(ofSize: fontSize)
@@ -1166,7 +1217,12 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
guard textView.markedTextRange == nil else {
return
}
- textView.typingAttributes = baseAttributes()
+ var attributes = baseAttributes()
+ // UIKit selects the paragraph's style when the caret moves; retain its bidi direction.
+ if let paragraph = textView.typingAttributes[.paragraphStyle] {
+ attributes[.paragraphStyle] = paragraph
+ }
+ textView.typingAttributes = attributes
}
private func applyTheme() {
diff --git a/apps/mobile/modules/t3-composer-editor/tests/ios/main.swift b/apps/mobile/modules/t3-composer-editor/tests/ios/main.swift
index 4435b1022161d..7568b2dd116b6 100644
--- a/apps/mobile/modules/t3-composer-editor/tests/ios/main.swift
+++ b/apps/mobile/modules/t3-composer-editor/tests/ios/main.swift
@@ -108,6 +108,45 @@ do {
)
}
+// Paragraph directions align and read each paragraph without changing source text or caret.
+do {
+ let view = T3ComposerEditorView()
+ view.frame = CGRect(x: 0, y: 0, width: 350, height: 200)
+ let text = "React עדכון הקוד בעברית\nEnglish stays left.\n"
+ let start = ("React עדכון הקוד בעברית\n" as NSString).length
+ let count = (text as NSString).length
+ var payload: [String: Any] = [
+ "value": text, "tokensJson": "[]", "mostRecentEventCount": 0, "isNativeEcho": false,
+ "resetGeneration": 0, "selection": ["start": count, "end": count],
+ "paragraphDirections": [
+ ["start": 0, "end": start, "direction": "rtl"],
+ ["start": start, "end": count, "direction": "ltr"],
+ ["start": count, "end": count, "direction": "ltr"],
+ ],
+ ]
+ view.setControlledDocumentJson(String(data: try! JSONSerialization.data(withJSONObject: payload), encoding: .utf8)!)
+ let input = view.editor
+ check(input.attributedText.string == text, "Source text changed")
+ check(input.selectedRange == NSRange(location: count, length: 0), "Caret changed")
+ let rtl = input.textStorage.attribute(.paragraphStyle, at: 0, effectiveRange: nil) as! NSParagraphStyle
+ let ltr = input.textStorage.attribute(.paragraphStyle, at: start, effectiveRange: nil) as! NSParagraphStyle
+ check(rtl.alignment == .right && rtl.baseWritingDirection == .rightToLeft, "Hebrew must align and read RTL")
+ check(ltr.alignment == .left && ltr.baseWritingDirection == .leftToRight, "English must align and read LTR")
+ view.layoutSubviews()
+ input.layoutManager.ensureLayout(for: input.textContainer)
+ let rtlGlyphs = input.layoutManager.glyphRange(forCharacterRange: NSRange(location: 0, length: start - 1), actualCharacterRange: nil)
+ let ltrGlyphs = input.layoutManager.glyphRange(forCharacterRange: NSRange(location: start, length: count - start - 1), actualCharacterRange: nil)
+ let rtlBounds = input.layoutManager.boundingRect(forGlyphRange: rtlGlyphs, in: input.textContainer)
+ let ltrBounds = input.layoutManager.boundingRect(forGlyphRange: ltrGlyphs, in: input.textContainer)
+ check(rtlBounds.maxX > 340 && ltrBounds.minX < 1, "Rendered paragraphs must use opposite edges")
+ let originalStorage = input.textStorage
+ payload["isNativeEcho"] = true
+ view.setControlledDocumentJson(String(data: try! JSONSerialization.data(withJSONObject: payload), encoding: .utf8)!)
+ check(input.textStorage === originalStorage && input.selectedRange.location == count, "Echo must preserve text storage and caret")
+ let typing = input.typingAttributes[.paragraphStyle] as! NSParagraphStyle
+ check(typing.baseWritingDirection == .leftToRight, "Trailing empty paragraph must inherit English")
+}
+
print("composer editor tests passed")
private let dropEditor = ComposerTextView()
diff --git a/apps/mobile/src/components/ui/AppText.test.tsx b/apps/mobile/src/components/ui/AppText.test.tsx
new file mode 100644
index 0000000000000..725e269b7652d
--- /dev/null
+++ b/apps/mobile/src/components/ui/AppText.test.tsx
@@ -0,0 +1,48 @@
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import { expect, it, vi } from "vite-plus/test";
+
+vi.mock("react-native", () => ({
+ Platform: { OS: process.env.T3_MOBILE_TEST_PLATFORM ?? "ios" },
+ Text: (props: { readonly style?: unknown; readonly children?: string }) => (
+ {props.children}
+ ),
+ TextInput: () => null,
+}));
+
+import { AppText } from "./AppText";
+
+function renderText(node: React.ReactNode) {
+ const host = document.createElement("div");
+ act(() => createRoot(host).render(node));
+ const span = host.querySelector("span")!;
+ return { text: span.textContent, style: JSON.parse(span.dataset.style!) };
+}
+
+it("sets the writing direction from plain-string text", () => {
+ expect(renderText(
Fix: באג parser
`); + const he = host.querySelector("#he")!; + const en = host.querySelector("#en")!; + expect(getComputedStyle(he).unicodeBidi).toBe("plaintext"); + expect(wordLeft(he, "באג")).toBeGreaterThan(wordLeft(he, "fix")); + expect(wordLeft(en, "Fix")).toBeLessThan(wordLeft(en, "parser")); + host.remove(); +}); + +it("leaves explicit dir, code and editors alone", () => { + const host = mount( + `שלום
שלום// הערה` +
+ `x
+
{children}
);
diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx
index 5a036e34611ed..276b1b9d63fcb 100644
--- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx
+++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx
@@ -1882,8 +1882,9 @@ function ComposerPromptEditorTiptapInner(
/>
{isEmpty && contextRecords.size === 0 && placeholder ? (
diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx
index 7ae210230a612..f11cc4ac5a212 100644
--- a/apps/web/src/components/GitActionsControl.tsx
+++ b/apps/web/src/components/GitActionsControl.tsx
@@ -128,6 +128,7 @@ import { sourceControlClients } from "@t3tools/client-runtime/source-control-cli
import { scopeProjectRef } from "@t3tools/client-runtime/environment";
import { formatDiffCount } from "@t3tools/client-runtime/diff-count";
import { useOpenLink } from "~/browser/useOpenLink";
+import { textDirection } from "~/textDirection";
interface GitActionsControlProps {
gitCwd: string | null;
@@ -1949,6 +1950,7 @@ export default function GitActionsControl({
Commit message (optional)
diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx
index 9098782006ef3..16287ad0aad90 100644
--- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx
+++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx
@@ -1043,7 +1043,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
) : (
{thread.title}
diff --git a/apps/web/src/index.css b/apps/web/src/index.css
index 0f9942e3295ee..23028d420863d 100644
--- a/apps/web/src/index.css
+++ b/apps/web/src/index.css
@@ -714,6 +714,20 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
* {
@apply border-border outline-ring/50;
}
+ /* Each text block takes its own base direction from its first strong letter, so Hebrew and
+ Arabic titles read right to left anywhere. Text under an explicit dir (majority-based
+ prose), code and editors keep their own handling. */
+ :where(p, span, div, li, h1, h2, h3, h4, h5, h6, td, th, label, a, button, dt, dd):not(
+ [dir],
+ [dir] *,
+ pre *,
+ code,
+ code *,
+ [contenteditable] *,
+ .xterm *
+ ) {
+ unicode-bidi: plaintext;
+ }
/* The app's 2px outlines stay inside controls at clipped and scrollport edges. */
*,
::before,
diff --git a/apps/web/src/textDirection.test.ts b/apps/web/src/textDirection.test.ts
index 0aa7b3c9e1f96..8acd7cb3c9a78 100644
--- a/apps/web/src/textDirection.test.ts
+++ b/apps/web/src/textDirection.test.ts
@@ -1,6 +1,14 @@
import { describe, expect, it } from "vite-plus/test";
-import { markdownProseDirection, textDirection } from "./textDirection";
+import {
+ composerParagraphDirections,
+ fieldDirection,
+ markdownCodeParts,
+ markdownPreview,
+ markdownProseDirection,
+ markdownWithDirection,
+ textDirection,
+} from "./textDirection";
describe("textDirection", () => {
it.each([
@@ -77,6 +85,15 @@ describe("markdownProseDirection", () => {
["Hebrew around a URL", "ראה https://github.com/pingdotgg/t3code/pull/24050 לפני", "rtl"],
["English around inline code", "Should I edit `useMemo` or keep it?", "ltr"],
["English quoting one Hebrew word", "Please check the שלום label before the release", "ltr"],
+ [
+ "a fence-looking line with trailing text inside code",
+ "```js\nconst x = 1;\n```not a close\nשלום שלום שלום שלום שלום שלום\n```\nDone",
+ "ltr",
+ ],
+ ["a shorter fence inside code", "````js\n```\nשלום שלום שלום\n````\nDone", "ltr"],
+ ["a different fence marker inside code", "~~~js\n```\nשלום שלום שלום\n~~~\nDone", "ltr"],
+ ["code nested in a block quote", "> ```js\n> שלום שלום שלום\n> ```\nDone", "ltr"],
+ ["a valid longer closing fence", "```js\nשלום שלום שלום\n```` \nDone", "ltr"],
])("reads %s", (_, markdown, direction) => {
expect(markdownProseDirection(markdown)).toBe(direction);
});
@@ -84,4 +101,123 @@ describe("markdownProseDirection", () => {
it("leaves markdown without prose letters to its surroundings", () => {
expect(markdownProseDirection("```\nconst a = 1;\n```")).toBeUndefined();
});
+
+ it("keeps unclosed fences inside their own question field", () => {
+ expect(markdownProseDirection("Question\n```ts\nx", "בחר באפשרות הזאת בבקשה")).toBe("rtl");
+ });
+});
+
+it("reads only the visible preview prose without losing truncated code boundaries", () => {
+ const text = `שלום עולם בבקשה תבדוק עכשיו\n\`run({ items: [1, 2] })\` ${"hidden English words ".repeat(20)}`;
+ const preview = markdownPreview(text, 42);
+ expect(preview.direction).toBe("rtl");
+ expect(preview.parts.map((part) => part.text).join("")).toBe(
+ text.replace(/\s+/g, " ").slice(0, 42) + "…",
+ );
+ expect(preview.parts.at(-1)?.code).toBe(true);
+});
+
+it("seeds native paragraphs without changing Markdown block structure or code", () => {
+ expect(markdownWithDirection("[!NOTE]", "rtl")).toBe("\u200f[!NOTE]");
+ for (const image of [
+ "",
+ "[](https://example.com)",
+ "![diagram][image]\n\n[image]: https://example.com/a.png",
+ ]) {
+ expect(markdownWithDirection(image, "rtl")).toBe(image);
+ }
+ const rich =
+ "| Name | Value |\n| --- | --- |\n| React | עברית |\n\n- [ ] React שינוי\n\n> [!NOTE]\n> React עדכון\n\n$$\nx^2\n$$\n\nReact המשך";
+ expect(markdownWithDirection(rich, "rtl")).toBe(
+ "| Name | Value |\n| --- | --- |\n| React | עברית |\n\n- [ ] \u200fReact שינוי\n\n> [!NOTE]\n> \u200fReact עדכון\n\n$$\nx^2\n$$\n\n\u200fReact המשך",
+ );
+ const markdown = "# React עדכון\n\n- React שינוי\n\n> English ציטוט\n\n```ts\nfoo()\n```";
+ expect(markdownWithDirection(markdown, "rtl")).toBe(
+ "# \u200fReact עדכון\n\n- \u200fReact שינוי\n\n> \u200fEnglish ציטוט\n\n```ts\nfoo()\n```",
+ );
+});
+
+it("preserves literal markdown while separating code from prose", () => {
+ const markdown =
+ "שלום\n```js\nvalue({ items: [1, 2] });\n```not a close\nעדיין קוד\n```\nאחרי `run()`";
+ const parts = markdownCodeParts(markdown);
+ expect(parts.map((part) => part.text).join("")).toBe(markdown);
+ expect(parts.filter((part) => part.code).map((part) => part.text)).toEqual([
+ "```js\nvalue({ items: [1, 2] });\n```not a close\nעדיין קוד\n```",
+ "`run()`",
+ ]);
+});
+
+it.each([
+ ["an English label in a Hebrew set", "GitHub token", "rtl", "ltr"],
+ [
+ "a mostly English label with a Hebrew word in a Hebrew set",
+ "Squash ל-feature branch",
+ "rtl",
+ "rtl",
+ ],
+ [
+ "a mostly English label with a Hebrew word in an English set",
+ "Squash ל-feature branch",
+ "ltr",
+ "ltr",
+ ],
+] as const)("fieldDirection reads %s", (_, text, setDirection, direction) => {
+ expect(fieldDirection(text, setDirection)).toBe(direction);
+});
+
+describe("composerParagraphDirections", () => {
+ const directions = (text: string) =>
+ composerParagraphDirections(text).map((paragraph) => paragraph.direction);
+
+ it("uses the desktop word majority and first-word tie break per paragraph", () => {
+ expect(
+ directions("React עדכון הקוד בעברית\nEnglish stays left.\nשלום React\nReact שלום"),
+ ).toEqual(["rtl", "ltr", "rtl", "ltr"]);
+ });
+
+ it("inherits prose direction across empty and numeric paragraphs, without inheriting code", () => {
+ expect(directions("اكتب الدالة\n\n42\n```\n// احسب مجموع القيم هنا\n```\n\nfix it\n")).toEqual([
+ "rtl",
+ "rtl",
+ "rtl",
+ "ltr",
+ "ltr",
+ "ltr",
+ "rtl",
+ "ltr",
+ "ltr",
+ ]);
+ });
+
+ it("excludes inline code and native chips from the word count", () => {
+ expect(directions("`useEffect` ו `useLayoutEffect` בעברית `React`")).toEqual(["rtl"]);
+ const chip = "@long-english-file-name ";
+ expect(composerParagraphDirections(chip + "שלום", [{ start: 0, end: chip.length }])).toEqual([
+ { start: 0, end: chip.length + 4, direction: "rtl" },
+ ]);
+ });
+
+ it("keeps a list or quote in one direction, as the desktop composer does", () => {
+ expect(directions("- Vitest\n- اختبر الدالة الجديدة\n- وأضف تعليق")).toEqual([
+ "rtl",
+ "rtl",
+ "rtl",
+ ]);
+ expect(directions("> React\n> עדכון הקוד בעברית")).toEqual(["rtl", "rtl"]);
+ });
+
+ it("uses native UTF-16 offsets and includes the trailing empty paragraph", () => {
+ const urlDraft = "https://github.com/foo/bar שלום\nEnglish https://example.com\n";
+ expect(composerParagraphDirections(urlDraft)).toEqual([
+ { start: 0, end: urlDraft.indexOf("\n") + 1, direction: "rtl" },
+ { start: urlDraft.indexOf("\n") + 1, end: urlDraft.length, direction: "ltr" },
+ { start: urlDraft.length, end: urlDraft.length, direction: "ltr" },
+ ]);
+ expect(composerParagraphDirections("😀 שלום\r\nEnglish\n")).toEqual([
+ { start: 0, end: 9, direction: "rtl" },
+ { start: 9, end: 17, direction: "ltr" },
+ { start: 17, end: 17, direction: "ltr" },
+ ]);
+ });
});
diff --git a/packages/shared/src/textDirection.ts b/packages/shared/src/textDirection.ts
index 6267380d435d4..12e31959a4f17 100644
--- a/packages/shared/src/textDirection.ts
+++ b/packages/shared/src/textDirection.ts
@@ -1,9 +1,16 @@
+import type { Root, RootContent } from "mdast";
+import remarkParse from "remark-parse";
+import remarkGfm from "remark-gfm";
+import remarkMath from "remark-math";
+import { unified } from "unified";
+
export type TextDirection = "ltr" | "rtl";
// Hebrew through Arabic Extended-A, their presentation forms, and the
// supplementary right-to-left blocks.
const RTL_LETTER = /[-ࣿיִ-﷿ﹰ-\u{10800}-\u{10FFF}\u{1E800}-\u{1EFFF}]/u;
const LETTER = /\p{L}/u;
+const HTTP_URL = /https?:\/\/\S+/g;
const SENTENCE_END = /(?<=[.!?؟])(?,
+ replacement = " ",
+) {
+ let offset = 0;
+ const parts: string[] = [];
+ for (const range of [...ranges].sort((a, b) => a.start - b.start)) {
+ const start = Math.max(offset, range.start);
+ const end = Math.min(text.length, range.end);
+ if (end <= start) continue;
+ parts.push(text.slice(offset, start), text.slice(start, end).replace(/[^\r\n]/g, replacement));
+ offset = end;
+ }
+ return parts.join("") + text.slice(offset);
+}
+
+/** Native paragraph ranges, using the desktop composer's prose-only word majority. */
+export function composerParagraphDirections(
+ text: string,
+ chips: ReadonlyArray<{ start: number; end: number }> = [],
+) {
+ // A chip is an atom, not indentation that could turn following prose into a code block.
+ const source = maskTextRanges(text, chips, "\ufffc");
+ const document = markdownParser.parse(source);
+ const code: Array<{ start: number; end: number }> = [];
+ const codeBlocks: Array<{ start: number; end: number }> = [];
+ const visit = (node: Root | RootContent): void => {
+ if (node.type === "code" || node.type === "inlineCode") {
+ const start = node.position?.start.offset;
+ const end = node.position?.end.offset;
+ if (start !== undefined && end !== undefined) {
+ code.push({ start, end });
+ if (node.type === "code") codeBlocks.push({ start, end });
+ }
+ } else if ("children" in node) node.children.forEach(visit);
+ };
+ visit(document);
+ const urls = Array.from(source.matchAll(HTTP_URL), (match) => ({
+ start: match.index,
+ end: match.index + match[0].length,
+ }));
+ const prose = maskTextRanges(source, [...code, ...urls]);
+ const groups = document.children.flatMap((node) => {
+ const start = node.position?.start.offset;
+ const end = node.position?.end.offset;
+ return (node.type === "list" || node.type === "blockquote") &&
+ start !== undefined &&
+ end !== undefined
+ ? [{ start, end, direction: textDirection(prose.slice(start, end)) }]
+ : [];
+ });
+ let previous: TextDirection | undefined;
+ const paragraphs: Array<{ start: number; end: number; direction: TextDirection }> = [];
+ for (const line of text.matchAll(/[^\r\n]*(?:\r\n|[\r\n]|$)/g)) {
+ const start = line.index;
+ const end = start + line[0].length;
+ if (start === end && text.length > 0 && !/[\r\n]$/.test(text)) continue;
+ if (codeBlocks.some((block) => start < block.end && end > block.start)) {
+ paragraphs.push({ start, end, direction: "ltr" });
+ continue;
+ }
+ const group = groups.find((block) => start < block.end && end > block.start);
+ const direction =
+ (group ? group.direction : textDirection(prose.slice(start, end))) ?? previous;
+ paragraphs.push({ start, end, direction: direction ?? "ltr" });
+ previous = direction;
+ }
+ return paragraphs;
+}
+
+export function markdownCodeParts(markdown: string) {
+ const parts: Array<{ text: string; code: boolean; start: number }> = [];
+ let offset = 0;
+ const visit = (node: Root | RootContent): void => {
+ if (node.type === "code" || node.type === "inlineCode") {
+ const start = node.position?.start.offset;
+ const end = node.position?.end.offset;
+ if (start === undefined || end === undefined) return;
+ if (start > offset) {
+ parts.push({ text: markdown.slice(offset, start), code: false, start: offset });
+ }
+ parts.push({ text: markdown.slice(start, end), code: true, start });
+ offset = end;
+ } else if ("children" in node) {
+ node.children.forEach(visit);
+ }
+ };
+ visit(markdownParser.parse(markdown));
+ if (offset < markdown.length) {
+ parts.push({ text: markdown.slice(offset), code: false, start: offset });
+ }
+ return parts;
+}
+
+function proseDirection(parts: ReadonlyArray<{ text: string; code: boolean }>) {
+ const prose = parts
+ .filter((part) => !part.code)
+ .map((part) => part.text)
+ .join(" ");
+ return textDirection(prose.replace(HTTP_URL, " "));
+}
+
const MARKDOWN_CODE_OR_URL =
- /(`{3,}|~{3,})[\s\S]*?(?:\1|$)|(`{1,2})(?!`)[\s\S]*?(?]*\1[`~]*[\t ]*(?=\n|$)|$)|(`{1,2})(?!`)[\s\S]*?(?) {
+ return textDirection(fields.map((field) => field.replace(MARKDOWN_CODE_OR_URL, " ")).join(" "));
+}
-export function markdownProseDirection(markdown: string): TextDirection | undefined {
- return textDirection(markdown.replace(MARKDOWN_CODE_OR_URL, " "));
+export function markdownPreview(markdown: string, maxChars: number) {
+ let previousSpace = false;
+ const normalized = [];
+ for (const part of markdownCodeParts(markdown)) {
+ let text = part.text.replace(/\s+/g, " ");
+ if (previousSpace && text.startsWith(" ")) text = text.slice(1);
+ if (text.length > 0) previousSpace = text.endsWith(" ");
+ normalized.push({ ...part, text });
+ }
+ const length = normalized.reduce((total, part) => total + part.text.length, 0);
+ const parts = [];
+ let rendered = 0;
+ for (const part of normalized) {
+ if (rendered >= maxChars) break;
+ let text = part.text.slice(0, maxChars - rendered);
+ rendered += text.length;
+ if (rendered === maxChars && length > maxChars) text += "…";
+ parts.push({ ...part, text });
+ }
+ return { parts, direction: proseDirection(parts) };
+}
+
+export function markdownWithDirection(markdown: string, direction: TextDirection | undefined) {
+ if (!direction) return markdown;
+ const offsets: number[] = [];
+ const visit = (node: Root | RootContent, parent?: Root | RootContent): void => {
+ if (node.type === "paragraph" || node.type === "heading") {
+ const child = node.children.length === 1 ? node.children[0] : undefined;
+ const media =
+ (child?.type === "link" || child?.type === "linkReference") && child.children.length === 1
+ ? child.children[0]
+ : child;
+ if (
+ node.type === "paragraph" &&
+ (media?.type === "image" || media?.type === "imageReference")
+ ) {
+ return;
+ }
+ let offset = node.children[0]?.position?.start.offset;
+ if (offset !== undefined) {
+ // Keep the native admonition marker at the start of its quote line.
+ const alert =
+ parent?.type === "blockquote" && parent.children[0] === node
+ ? /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\][\t ]*(?:\r?\n[\t ]*(?:>[\t ]*)*|$)/i.exec(
+ markdown.slice(offset, node.position?.end.offset),
+ )
+ : null;
+ if (alert) offset += alert[0].length;
+ if (offset < (node.position?.end.offset ?? markdown.length)) offsets.push(offset);
+ }
+ }
+ if ("children" in node) node.children.forEach((child) => visit(child, node));
+ };
+ visit(nativeMarkdownParser.parse(markdown));
+ const mark = direction === "rtl" ? "\u200f" : "\u200e";
+ for (const offset of offsets.reverse()) {
+ markdown = markdown.slice(0, offset) + mark + markdown.slice(offset);
+ }
+ return markdown;
}