courrier preview

This commit is contained in:
alma 2025-05-01 09:32:23 +02:00
parent 9e5c2cb92e
commit 193a265109
7 changed files with 470 additions and 71 deletions

View File

@ -15,6 +15,8 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import RichTextEditor from '@/components/ui/rich-text-editor';
import { detectTextDirection } from '@/lib/utils/text-direction';
// Import from the centralized utils
import {
@ -346,12 +348,13 @@ export default function ComposeEmail(props: ComposeEmailProps) {
</div>
{/* Message Body */}
<div
<RichTextEditor
ref={editorRef}
className="min-h-[320px] outline-none p-2 border rounded-md bg-white text-gray-800 flex-1"
contentEditable={true}
dangerouslySetInnerHTML={{ __html: emailContent }}
onInput={(e) => setEmailContent(e.currentTarget.innerHTML)}
initialContent={emailContent}
initialDirection={detectTextDirection(emailContent)}
onChange={setEmailContent}
className="min-h-[320px] border rounded-md bg-white text-gray-800 flex-1"
placeholder="Write your message here..."
/>
{/* Attachments */}

View File

@ -1,7 +1,9 @@
'use client';
import React from 'react';
import React, { useMemo } from 'react';
import { EmailContent } from '@/types/email';
import { detectTextDirection } from '@/lib/utils/text-direction';
import DOMPurify from 'isomorphic-dompurify';
interface EmailContentDisplayProps {
content: EmailContent | null | undefined;
@ -13,7 +15,7 @@ interface EmailContentDisplayProps {
/**
* Unified component for displaying email content in a consistent way
* This handles both HTML and plain text content with proper styling
* This handles both HTML and plain text content with proper styling and RTL support
*/
const EmailContentDisplay: React.FC<EmailContentDisplayProps> = ({
content,
@ -22,42 +24,95 @@ const EmailContentDisplay: React.FC<EmailContentDisplayProps> = ({
type = 'auto',
debug = false
}) => {
if (!content) {
return <div className={className}>No content available</div>;
}
// Create a safe content object with fallback values for missing properties
const safeContent = useMemo(() => {
if (!content) {
return {
text: '',
html: undefined,
isHtml: false,
direction: 'ltr' as const
};
}
return {
text: content.text || '',
html: content.html,
isHtml: content.isHtml,
// If direction is missing, detect it from the text content
direction: content.direction || detectTextDirection(content.text)
};
}, [content]);
// Determine what content to display based on type preference and available content
const htmlToDisplay = useMemo(() => {
// If no content is available, show a placeholder
if (!safeContent.text && !safeContent.html) {
return '<div class="text-gray-400">No content available</div>';
}
// If type is explicitly set to text, or we don't have HTML and auto mode
if (type === 'text' || (type === 'auto' && !safeContent.isHtml)) {
// Format plain text with line breaks for HTML display
if (safeContent.text) {
const formattedText = safeContent.text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>');
return formattedText;
}
}
// Otherwise use HTML content if available
if (safeContent.isHtml && safeContent.html) {
return safeContent.html;
}
// Fallback to text content if there's no HTML
if (safeContent.text) {
const formattedText = safeContent.text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>');
return formattedText;
}
return '<div class="text-gray-400">No content available</div>';
}, [safeContent, type]);
let htmlContent = '';
// Handle quoted text display
const processedHTML = useMemo(() => {
if (!showQuotedText) {
// This is simplified - a more robust approach would parse and handle
// quoted sections more intelligently
return htmlToDisplay.replace(/<blockquote[^>]*>[\s\S]*?<\/blockquote>/gi,
'<div class="text-gray-400">[Quoted text hidden]</div>');
}
return htmlToDisplay;
}, [htmlToDisplay, showQuotedText]);
// Simple content rendering
if (content.isHtml && content.html) {
// Use HTML content
htmlContent = content.html;
} else if (content.text) {
// Format text content with line breaks
htmlContent = content.text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>');
} else {
// No content available
htmlContent = 'No content available';
}
// Sanitize HTML content before rendering
const sanitizedHTML = useMemo(() => {
return DOMPurify.sanitize(processedHTML);
}, [processedHTML]);
return (
<div className={`email-content-display ${className}`}>
<div className={`email-content-display ${className}`} dir={safeContent.direction}>
<div
className="email-content-inner"
dangerouslySetInnerHTML={{ __html: htmlContent }}
dangerouslySetInnerHTML={{ __html: sanitizedHTML }}
/>
{/* Debug output if enabled */}
{debug && (
<div className="mt-4 p-2 text-xs bg-gray-100 border rounded">
<p><strong>Content Type:</strong> {content.isHtml ? 'HTML' : 'Text'}</p>
<p><strong>Direction:</strong> {content.direction}</p>
<p><strong>HTML Length:</strong> {content.html?.length || 0}</p>
<p><strong>Text Length:</strong> {content.text?.length || 0}</p>
<p><strong>Content Type:</strong> {safeContent.isHtml ? 'HTML' : 'Text'}</p>
<p><strong>Direction:</strong> {safeContent.direction}</p>
<p><strong>HTML Length:</strong> {safeContent.html?.length || 0}</p>
<p><strong>Text Length:</strong> {safeContent.text?.length || 0}</p>
</div>
)}
@ -66,10 +121,30 @@ const EmailContentDisplay: React.FC<EmailContentDisplayProps> = ({
width: 100%;
}
.email-content-display[dir="rtl"] {
text-align: right;
}
.email-content-inner img {
max-width: 100%;
height: auto;
}
.email-content-inner blockquote {
margin: 10px 0;
padding-left: 15px;
border-left: 2px solid #ddd;
color: #666;
background-color: #f9f9f9;
border-radius: 4px;
}
.email-content-display[dir="rtl"] .email-content-inner blockquote {
padding-left: 0;
padding-right: 15px;
border-left: none;
border-right: 2px solid #ddd;
}
`}</style>
</div>
);

View File

@ -0,0 +1,171 @@
'use client';
import React, { useState, useEffect, useRef, forwardRef, useImperativeHandle } from 'react';
import { detectTextDirection } from '@/lib/utils/text-direction';
import DOMPurify from 'isomorphic-dompurify';
interface RichTextEditorProps {
/** Initial HTML content */
initialContent?: string;
/** Callback when content changes */
onChange?: (html: string) => void;
/** Additional CSS class names */
className?: string;
/** Editor placeholder text */
placeholder?: string;
/** Whether the editor is read-only */
readOnly?: boolean;
/** Minimum height of the editor */
minHeight?: string;
/** Initial text direction */
initialDirection?: 'ltr' | 'rtl';
}
/**
* Unified rich text editor component with proper RTL support
* Handles email composition with appropriate text direction detection
*/
const RichTextEditor = forwardRef<HTMLDivElement, RichTextEditorProps>(({
initialContent = '',
onChange,
className = '',
placeholder = 'Write your message...',
readOnly = false,
minHeight = '200px',
initialDirection
}, ref) => {
const internalEditorRef = useRef<HTMLDivElement>(null);
const [direction, setDirection] = useState<'ltr' | 'rtl'>(
initialDirection || detectTextDirection(initialContent)
);
// Forward the ref to parent components
useImperativeHandle(ref, () => internalEditorRef.current as HTMLDivElement);
// Initialize editor with clean content
useEffect(() => {
if (internalEditorRef.current) {
// Clean the initial content
const cleanContent = DOMPurify.sanitize(initialContent);
internalEditorRef.current.innerHTML = cleanContent;
// Set initial direction
internalEditorRef.current.setAttribute('dir', direction);
// Focus editor if not read-only
if (!readOnly) {
setTimeout(() => {
internalEditorRef.current?.focus();
}, 100);
}
}
}, [initialContent, direction, readOnly]);
// Handle content changes and detect direction changes
const handleInput = (e: React.FormEvent<HTMLDivElement>) => {
if (onChange && e.currentTarget.innerHTML !== initialContent) {
onChange(e.currentTarget.innerHTML);
}
// Re-detect direction on significant content changes
// Only do this when the content length has changed significantly
const newContent = e.currentTarget.innerText;
if (newContent.length > 5 && newContent.length % 10 === 0) {
const newDirection = detectTextDirection(newContent);
if (newDirection !== direction) {
setDirection(newDirection);
e.currentTarget.setAttribute('dir', newDirection);
}
}
};
// Toggle direction manually
const toggleDirection = () => {
const newDirection = direction === 'ltr' ? 'rtl' : 'ltr';
setDirection(newDirection);
if (internalEditorRef.current) {
internalEditorRef.current.setAttribute('dir', newDirection);
}
};
return (
<div className="rich-text-editor-container">
{!readOnly && (
<div className="editor-toolbar border-b p-1 flex items-center space-x-1">
<button
type="button"
onClick={toggleDirection}
className="px-2 py-1 text-xs bg-gray-100 hover:bg-gray-200 rounded"
title={`Switch to ${direction === 'ltr' ? 'right-to-left' : 'left-to-right'} text`}
>
{direction === 'ltr' ? 'LTR → RTL' : 'RTL → LTR'}
</button>
</div>
)}
<div
ref={internalEditorRef}
contentEditable={!readOnly}
className={`rich-text-editor outline-none p-3 ${className}`}
onInput={handleInput}
dir={direction}
style={{
minHeight,
cursor: readOnly ? 'default' : 'text',
}}
data-placeholder={placeholder}
suppressContentEditableWarning
/>
<style jsx>{`
.rich-text-editor {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
font-size: 14px;
line-height: 1.6;
overflow-wrap: break-word;
word-break: break-word;
white-space: pre-wrap;
}
.rich-text-editor:empty:before {
content: attr(data-placeholder);
color: #aaa;
pointer-events: none;
}
.rich-text-editor[dir="rtl"] {
text-align: right;
}
.rich-text-editor blockquote {
margin: 10px 0;
padding-left: 15px;
border-left: 2px solid #ddd;
color: #666;
}
.rich-text-editor[dir="rtl"] blockquote {
padding-left: 0;
padding-right: 15px;
border-left: none;
border-right: 2px solid #ddd;
}
.rich-text-editor img {
max-width: 100%;
height: auto;
}
`}</style>
</div>
);
});
RichTextEditor.displayName = 'RichTextEditor';
export default RichTextEditor;

View File

@ -1,6 +1,8 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useToast } from './use-toast';
import { EmailMessage, EmailContent } from '@/types/email';
import { detectTextDirection } from '@/lib/utils/text-direction';
import { sanitizeHtml } from '@/lib/utils/email-utils';
interface EmailFetchState {
email: EmailMessage | null;
@ -73,6 +75,50 @@ export function useEmailFetch({ onEmailLoaded, onError }: UseEmailFetchProps = {
const data = await response.json();
// Create a valid email message object with required fields
const processContent = (data: any): EmailContent => {
// Determine the text content - using all possible paths
let textContent = '';
if (typeof data.content === 'string') {
textContent = data.content;
} else if (data.content?.text) {
textContent = data.content.text;
} else if (data.text) {
textContent = data.text;
} else if (data.plainText) {
textContent = data.plainText;
}
// Determine the HTML content - using all possible paths
let htmlContent = undefined;
if (data.content?.html) {
htmlContent = data.content.html;
} else if (data.html) {
htmlContent = data.html;
} else if (typeof data.content === 'string' && data.content.includes('<')) {
// If the content string appears to be HTML
htmlContent = data.content;
// We should still keep the text version, will be extracted if needed
}
// Clean HTML content if present
if (htmlContent) {
htmlContent = sanitizeHtml(htmlContent);
}
// Determine if content is HTML
const isHtml = !!htmlContent;
// Detect text direction
const direction = data.content?.direction || detectTextDirection(textContent);
return {
text: textContent,
html: htmlContent,
isHtml,
direction
};
};
const transformedEmail: EmailMessage = {
id: data.id || emailId,
subject: data.subject || '',
@ -82,14 +128,7 @@ export function useEmailFetch({ onEmailLoaded, onError }: UseEmailFetchProps = {
bcc: data.bcc,
date: data.date || new Date().toISOString(),
flags: Array.isArray(data.flags) ? data.flags : [],
content: {
text: typeof data.content === 'string' ? data.content :
data.content?.text || data.text || '',
html: data.content?.html || data.html || undefined,
isHtml: !!(data.content?.html || data.html ||
(typeof data.content === 'string' && data.content.includes('<'))),
direction: data.content?.direction || 'ltr'
},
content: processContent(data),
attachments: data.attachments
};

View File

@ -17,6 +17,7 @@ import {
} from '@/types/email';
import { adaptLegacyEmail } from '@/lib/utils/email-adapters';
import { decodeInfomaniakEmail, adaptMimeEmail, isMimeFormat } from './email-mime-decoder';
import { detectTextDirection } from '@/lib/utils/text-direction';
// Reset any existing hooks to start clean
DOMPurify.removeAllHooks();
@ -38,15 +39,6 @@ DOMPurify.setConfig({
ALLOWED_ATTR: ['style', 'class', 'id', 'dir']
});
/**
* Detect if text contains RTL characters
*/
export function detectTextDirection(text: string): 'ltr' | 'rtl' {
// Pattern for RTL characters (Arabic, Hebrew, etc.)
const rtlLangPattern = /[\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC]/;
return rtlLangPattern.test(text) ? 'rtl' : 'ltr';
}
/**
* Format email addresses for display
* Can handle both array of EmailAddress objects or a string
@ -224,10 +216,18 @@ export function renderEmailContent(content: EmailContent): string {
return `<div class="email-content plain-text" dir="${content.direction || 'ltr'}">${formattedText}</div>`;
}
// Add interface for email formatting functions
interface FormattedEmail {
to: string;
cc?: string;
subject: string;
content: EmailContent;
}
/**
* Format email for reply
*/
export function formatReplyEmail(originalEmail: any, type: 'reply' | 'reply-all' = 'reply') {
export function formatReplyEmail(originalEmail: EmailMessage | LegacyEmailMessage | null, type: 'reply' | 'reply-all' = 'reply'): FormattedEmail {
if (!originalEmail) {
return {
to: '',
@ -244,7 +244,7 @@ export function formatReplyEmail(originalEmail: any, type: 'reply' | 'reply-all'
// Format the recipients
const to = Array.isArray(originalEmail.from)
? originalEmail.from.map(addr => {
? originalEmail.from.map((addr: any) => {
if (typeof addr === 'string') return addr;
return addr.address ? addr.address : '';
}).filter(Boolean).join(', ')
@ -256,7 +256,7 @@ export function formatReplyEmail(originalEmail: any, type: 'reply' | 'reply-all'
let cc = '';
if (type === 'reply-all') {
const toRecipients = Array.isArray(originalEmail.to)
? originalEmail.to.map(addr => {
? originalEmail.to.map((addr: any) => {
if (typeof addr === 'string') return addr;
return addr.address ? addr.address : '';
}).filter(Boolean)
@ -265,7 +265,7 @@ export function formatReplyEmail(originalEmail: any, type: 'reply' | 'reply-all'
: [];
const ccRecipients = Array.isArray(originalEmail.cc)
? originalEmail.cc.map(addr => {
? originalEmail.cc.map((addr: any) => {
if (typeof addr === 'string') return addr;
return addr.address ? addr.address : '';
}).filter(Boolean)
@ -286,7 +286,7 @@ export function formatReplyEmail(originalEmail: any, type: 'reply' | 'reply-all'
const dateStr = originalDate.toLocaleString();
const fromStr = Array.isArray(originalEmail.from)
? originalEmail.from.map(addr => {
? originalEmail.from.map((addr: any) => {
if (typeof addr === 'string') return addr;
return addr.name ? `${addr.name} <${addr.address}>` : addr.address;
}).join(', ')
@ -295,7 +295,7 @@ export function formatReplyEmail(originalEmail: any, type: 'reply' | 'reply-all'
: 'Unknown Sender';
const toStr = Array.isArray(originalEmail.to)
? originalEmail.to.map(addr => {
? originalEmail.to.map((addr: any) => {
if (typeof addr === 'string') return addr;
return addr.name ? `${addr.name} <${addr.address}>` : addr.address;
}).join(', ')
@ -303,24 +303,40 @@ export function formatReplyEmail(originalEmail: any, type: 'reply' | 'reply-all'
? originalEmail.to
: '';
// Create HTML content
// Extract original content
const originalTextContent =
originalEmail.content?.text ||
(typeof originalEmail.content === 'string' ? originalEmail.content : '');
const originalHtmlContent =
originalEmail.content?.html ||
originalEmail.html ||
(typeof originalEmail.content === 'string' && originalEmail.content.includes('<')
? originalEmail.content
: '');
// Get the direction from the original email
const originalDirection =
originalEmail.content?.direction ||
(originalTextContent ? detectTextDirection(originalTextContent) : 'ltr');
// Create HTML content that preserves the directionality
const htmlContent = `
<br/>
<br/>
<div class="email-original-content">
<div class="email-original-content" dir="${originalDirection}">
<blockquote style="border-left: 2px solid #ddd; padding-left: 10px; margin: 10px 0; color: #505050;">
<p>On ${dateStr}, ${fromStr} wrote:</p>
${originalEmail.content?.html || originalEmail.content?.text || ''}
${originalHtmlContent || originalTextContent.replace(/\n/g, '<br>')}
</blockquote>
</div>
`;
// Create plain text content
const plainText = originalEmail.content?.text || '';
const textContent = `
On ${dateStr}, ${fromStr} wrote:
> ${plainText.split('\n').join('\n> ')}
> ${originalTextContent.split('\n').join('\n> ')}
`;
return {
@ -331,7 +347,7 @@ On ${dateStr}, ${fromStr} wrote:
text: textContent,
html: htmlContent,
isHtml: true,
direction: 'ltr' as const
direction: 'ltr' as const // Reply is LTR, but original content keeps its direction in the blockquote
}
};
}
@ -339,7 +355,7 @@ On ${dateStr}, ${fromStr} wrote:
/**
* Format email for forwarding
*/
export function formatForwardedEmail(originalEmail: any) {
export function formatForwardedEmail(originalEmail: EmailMessage | LegacyEmailMessage | null): FormattedEmail {
if (!originalEmail) {
return {
to: '',
@ -360,7 +376,7 @@ export function formatForwardedEmail(originalEmail: any) {
// Format from, to, cc for the header
const fromStr = Array.isArray(originalEmail.from)
? originalEmail.from.map(addr => {
? originalEmail.from.map((addr: any) => {
if (typeof addr === 'string') return addr;
return addr.name ? `${addr.name} <${addr.address}>` : addr.address;
}).join(', ')
@ -369,7 +385,7 @@ export function formatForwardedEmail(originalEmail: any) {
: 'Unknown Sender';
const toStr = Array.isArray(originalEmail.to)
? originalEmail.to.map(addr => {
? originalEmail.to.map((addr: any) => {
if (typeof addr === 'string') return addr;
return addr.name ? `${addr.name} <${addr.address}>` : addr.address;
}).join(', ')
@ -378,7 +394,7 @@ export function formatForwardedEmail(originalEmail: any) {
: '';
const ccStr = Array.isArray(originalEmail.cc)
? originalEmail.cc.map(addr => {
? originalEmail.cc.map((addr: any) => {
if (typeof addr === 'string') return addr;
return addr.name ? `${addr.name} <${addr.address}>` : addr.address;
}).join(', ')
@ -388,19 +404,36 @@ export function formatForwardedEmail(originalEmail: any) {
const dateStr = originalEmail.date ? new Date(originalEmail.date).toLocaleString() : 'Unknown Date';
// Create HTML content
// Extract original content
const originalTextContent =
originalEmail.content?.text ||
(typeof originalEmail.content === 'string' ? originalEmail.content : '');
const originalHtmlContent =
originalEmail.content?.html ||
originalEmail.html ||
(typeof originalEmail.content === 'string' && originalEmail.content.includes('<')
? originalEmail.content
: '');
// Get the direction from the original email
const originalDirection =
originalEmail.content?.direction ||
(originalTextContent ? detectTextDirection(originalTextContent) : 'ltr');
// Create HTML content that preserves the directionality
const htmlContent = `
<br/>
<br/>
<div class="email-original-content">
<div class="email-forwarded-content">
<p>---------- Forwarded message ---------</p>
<p><strong>From:</strong> ${fromStr}</p>
<p><strong>Date:</strong> ${dateStr}</p>
<p><strong>Subject:</strong> ${originalEmail.subject || ''}</p>
<p><strong>To:</strong> ${toStr}</p>
${ccStr ? `<p><strong>Cc:</strong> ${ccStr}</p>` : ''}
<div style="margin-top: 15px; border-top: 1px solid #eee; padding-top: 15px;">
${originalEmail.content?.html || originalEmail.content?.text || ''}
<div style="margin-top: 15px; border-top: 1px solid #eee; padding-top: 15px;" dir="${originalDirection}">
${originalHtmlContent || originalTextContent.replace(/\n/g, '<br>')}
</div>
</div>
`;
@ -415,7 +448,7 @@ Subject: ${originalEmail.subject || ''}
To: ${toStr}
${ccStr ? `Cc: ${ccStr}\n` : ''}
${originalEmail.content?.text || ''}
${originalTextContent}
`;
return {
@ -425,7 +458,7 @@ ${originalEmail.content?.text || ''}
text: textContent,
html: htmlContent,
isHtml: true,
direction: 'ltr' as const
direction: 'ltr' as const // Forward is LTR, but original content keeps its direction
}
};
}

View File

@ -0,0 +1,64 @@
/**
* Text Direction Utilities
*
* Centralized utilities for handling text direction (RTL/LTR)
* to ensure consistent behavior across the application.
*/
/**
* Detects if text contains RTL characters and should be displayed right-to-left
* Uses a comprehensive regex pattern that covers Arabic, Hebrew, and other RTL scripts
*
* @param text Text to analyze for direction
* @returns 'rtl' if RTL characters are detected, otherwise 'ltr'
*/
export function detectTextDirection(text: string | undefined | null): 'ltr' | 'rtl' {
if (!text) return 'ltr';
// Comprehensive pattern for RTL languages:
// - Arabic (0600-06FF, FB50-FDFF, FE70-FEFF)
// - Hebrew (0590-05FF, FB1D-FB4F)
// - RTL marks and controls (200F, 202B, 202E)
const rtlPattern = /[\u0591-\u07FF\u200F\u202B\u202E\uFB1D-\uFDFD\uFE70-\uFEFC]/;
return rtlPattern.test(text) ? 'rtl' : 'ltr';
}
/**
* Adds appropriate direction attribute to HTML content based on content analysis
*
* @param htmlContent HTML content to analyze and enhance with direction
* @param textContent Plain text version for direction analysis (optional)
* @returns HTML with appropriate direction attribute
*/
export function applyTextDirection(htmlContent: string, textContent?: string): string {
if (!htmlContent) return '';
// If text content is provided, use it for direction detection
// Otherwise extract text from HTML for direction detection
const textForAnalysis = textContent ||
htmlContent.replace(/<[^>]*>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&');
const direction = detectTextDirection(textForAnalysis);
// If the HTML already has a dir attribute, don't override it
if (htmlContent.includes('dir="rtl"') || htmlContent.includes('dir="ltr"')) {
return htmlContent;
}
// Check if we already have an email-content wrapper
if (htmlContent.startsWith('<div class="email-content')) {
// Replace opening div with one that includes direction
return htmlContent.replace(
/<div class="email-content([^"]*)"/,
`<div class="email-content$1" dir="${direction}"`
);
}
// Otherwise, wrap the content with a direction-aware container
return `<div class="email-content" dir="${direction}">${htmlContent}</div>`;
}

View File

@ -19,10 +19,24 @@ export interface EmailAttachment {
contentId?: string;
}
/**
* Standard email content structure used throughout the application
* Ensures consistent handling of HTML/text content and text direction
*/
export interface EmailContent {
/** Plain text version of the content (always required) */
text: string;
/** HTML version of the content (optional) */
html?: string;
/** Whether the primary display format should be HTML */
isHtml: boolean;
/**
* Text direction - 'rtl' for right-to-left languages (Arabic, Hebrew, etc.)
* or 'ltr' for left-to-right languages (default)
*/
direction: 'ltr' | 'rtl';
}