mirror of
https://github.com/farcasclaudiu/openclaw.git
synced 2026-06-28 21:01:43 +03:00
feat(ui): add RTL support for Hebrew/Arabic text in webchat (openclaw#11498) thanks @dirbalak
Verified: - pnpm install --frozen-lockfile - pnpm build - pnpm check - pnpm test Co-authored-by: dirbalak <30323349+dirbalak@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
@@ -199,6 +199,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Providers: add xAI (Grok) support. (#9885) Thanks @grp06.
|
- Providers: add xAI (Grok) support. (#9885) Thanks @grp06.
|
||||||
- Providers: add Baidu Qianfan support. (#8868) Thanks @ide-rea.
|
- Providers: add Baidu Qianfan support. (#8868) Thanks @ide-rea.
|
||||||
- Web UI: add token usage dashboard. (#10072) Thanks @Takhoffman.
|
- Web UI: add token usage dashboard. (#10072) Thanks @Takhoffman.
|
||||||
|
- Web UI: add RTL auto-direction support for Hebrew/Arabic text in chat composer and rendered messages. (#11498) Thanks @dirbalak.
|
||||||
- Memory: native Voyage AI support. (#7078) Thanks @mcinteerj.
|
- Memory: native Voyage AI support. (#7078) Thanks @mcinteerj.
|
||||||
- Sessions: cap sessions_history payloads to reduce context overflow. (#10000) Thanks @gut-puncture.
|
- Sessions: cap sessions_history payloads to reduce context overflow. (#10000) Thanks @gut-puncture.
|
||||||
- CLI: sort commands alphabetically in help output. (#8068) Thanks @deepsoumya617.
|
- CLI: sort commands alphabetically in help output. (#8068) Thanks @deepsoumya617.
|
||||||
|
|||||||
@@ -122,3 +122,23 @@
|
|||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
margin: 1em 0;
|
margin: 1em 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* =============================================
|
||||||
|
RTL (Right-to-Left) SUPPORT
|
||||||
|
============================================= */
|
||||||
|
|
||||||
|
.chat-text[dir="rtl"] {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-text[dir="rtl"] :where(ul, ol) {
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-text[dir="rtl"] :where(blockquote) {
|
||||||
|
border-left: none;
|
||||||
|
border-right: 3px solid var(--border);
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 1em;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
|||||||
import type { AssistantIdentity } from "../assistant-identity.ts";
|
import type { AssistantIdentity } from "../assistant-identity.ts";
|
||||||
import type { MessageGroup } from "../types/chat-types.ts";
|
import type { MessageGroup } from "../types/chat-types.ts";
|
||||||
import { toSanitizedMarkdownHtml } from "../markdown.ts";
|
import { toSanitizedMarkdownHtml } from "../markdown.ts";
|
||||||
|
import { detectTextDirection } from "../text-direction.ts";
|
||||||
import { renderCopyAsMarkdownButton } from "./copy-as-markdown.ts";
|
import { renderCopyAsMarkdownButton } from "./copy-as-markdown.ts";
|
||||||
import {
|
import {
|
||||||
extractTextCached,
|
extractTextCached,
|
||||||
@@ -272,7 +273,7 @@ function renderGroupedMessage(
|
|||||||
}
|
}
|
||||||
${
|
${
|
||||||
markdown
|
markdown
|
||||||
? html`<div class="chat-text">${unsafeHTML(toSanitizedMarkdownHtml(markdown))}</div>`
|
? html`<div class="chat-text" dir="${detectTextDirection(markdown)}">${unsafeHTML(toSanitizedMarkdownHtml(markdown))}</div>`
|
||||||
: nothing
|
: nothing
|
||||||
}
|
}
|
||||||
${toolCards.map((card) => renderToolCardSidebar(card, onOpenSidebar))}
|
${toolCards.map((card) => renderToolCardSidebar(card, onOpenSidebar))}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { detectTextDirection } from "./text-direction.ts";
|
||||||
|
|
||||||
|
describe("detectTextDirection", () => {
|
||||||
|
it("returns ltr for null and empty input", () => {
|
||||||
|
expect(detectTextDirection(null)).toBe("ltr");
|
||||||
|
expect(detectTextDirection("")).toBe("ltr");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects rtl when first significant char is rtl script", () => {
|
||||||
|
expect(detectTextDirection("שלום עולם")).toBe("rtl");
|
||||||
|
expect(detectTextDirection("مرحبا")).toBe("rtl");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects ltr when first significant char is ltr", () => {
|
||||||
|
expect(detectTextDirection("Hello world")).toBe("ltr");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips punctuation and markdown prefix characters before detection", () => {
|
||||||
|
expect(detectTextDirection("**שלום")).toBe("rtl");
|
||||||
|
expect(detectTextDirection("# مرحبا")).toBe("rtl");
|
||||||
|
expect(detectTextDirection("- hello")).toBe("ltr");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* RTL (Right-to-Left) text direction detection.
|
||||||
|
* Detects Hebrew, Arabic, Syriac, Thaana, Nko, Samaritan, Mandaic, Adlam,
|
||||||
|
* Phoenician, and Lydian scripts using Unicode Script Properties.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const RTL_CHAR_REGEX =
|
||||||
|
/\p{Script=Hebrew}|\p{Script=Arabic}|\p{Script=Syriac}|\p{Script=Thaana}|\p{Script=Nko}|\p{Script=Samaritan}|\p{Script=Mandaic}|\p{Script=Adlam}|\p{Script=Phoenician}|\p{Script=Lydian}/u;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect text direction from the first significant character.
|
||||||
|
* @param text - The text to check
|
||||||
|
* @param skipPattern - Characters to skip when looking for the first significant char.
|
||||||
|
* Defaults to whitespace and Unicode punctuation/symbols.
|
||||||
|
*/
|
||||||
|
export function detectTextDirection(
|
||||||
|
text: string | null,
|
||||||
|
skipPattern: RegExp = /[\s\p{P}\p{S}]/u,
|
||||||
|
): "rtl" | "ltr" {
|
||||||
|
if (!text) {
|
||||||
|
return "ltr";
|
||||||
|
}
|
||||||
|
for (const char of text) {
|
||||||
|
if (skipPattern.test(char)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return RTL_CHAR_REGEX.test(char) ? "rtl" : "ltr";
|
||||||
|
}
|
||||||
|
return "ltr";
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from "../chat/grouped-render.ts";
|
} from "../chat/grouped-render.ts";
|
||||||
import { normalizeMessage, normalizeRoleForGrouping } from "../chat/message-normalizer.ts";
|
import { normalizeMessage, normalizeRoleForGrouping } from "../chat/message-normalizer.ts";
|
||||||
import { icons } from "../icons.ts";
|
import { icons } from "../icons.ts";
|
||||||
|
import { detectTextDirection } from "../text-direction.ts";
|
||||||
import { renderMarkdownSidebar } from "./markdown-sidebar.ts";
|
import { renderMarkdownSidebar } from "./markdown-sidebar.ts";
|
||||||
import "../components/resizable-divider.ts";
|
import "../components/resizable-divider.ts";
|
||||||
|
|
||||||
@@ -375,6 +376,7 @@ export function renderChat(props: ChatProps) {
|
|||||||
<textarea
|
<textarea
|
||||||
${ref((el) => el && adjustTextareaHeight(el as HTMLTextAreaElement))}
|
${ref((el) => el && adjustTextareaHeight(el as HTMLTextAreaElement))}
|
||||||
.value=${props.draft}
|
.value=${props.draft}
|
||||||
|
dir=${detectTextDirection(props.draft)}
|
||||||
?disabled=${!props.connected}
|
?disabled=${!props.connected}
|
||||||
@keydown=${(e: KeyboardEvent) => {
|
@keydown=${(e: KeyboardEvent) => {
|
||||||
if (e.key !== "Enter") {
|
if (e.key !== "Enter") {
|
||||||
|
|||||||
Reference in New Issue
Block a user