diff --git a/packages/affine/blocks/list/src/list-block.ts b/packages/affine/blocks/list/src/list-block.ts index 1d83b5db7285..05d0b93c8994 100644 --- a/packages/affine/blocks/list/src/list-block.ts +++ b/packages/affine/blocks/list/src/list-block.ts @@ -11,7 +11,7 @@ import { EDGELESS_TOP_CONTENTEDITABLE_SELECTOR, } from '@blocksuite/affine-shared/consts'; import { DocModeProvider } from '@blocksuite/affine-shared/services'; -import { getViewportElement } from '@blocksuite/affine-shared/utils'; +import { getViewportElement, getTextDirection } from '@blocksuite/affine-shared/utils'; import type { BlockComponent } from '@blocksuite/std'; import { BlockSelection, TextSelection } from '@blocksuite/std'; import { @@ -194,6 +194,7 @@ export class ListBlockComponent extends CaptionedBlockComponent .inlineRangeProvider=${this._inlineRangeProvider} .enableClipboard=${false} .enableUndoRedo=${false} + .textDirection=${getTextDirection(this.model.props.text.yText.toString())} .verticalScrollContainerGetter=${() => getViewportElement(this.host)} > diff --git a/packages/affine/blocks/list/src/styles.ts b/packages/affine/blocks/list/src/styles.ts index fcb9a689ed59..efb01cec94df 100644 --- a/packages/affine/blocks/list/src/styles.ts +++ b/packages/affine/blocks/list/src/styles.ts @@ -65,5 +65,20 @@ export const listBlockStyles = css` color: var(--affine-text-secondary-color); } + /* RTL support for list blocks */ + [dir='rtl'] .affine-list-rich-text-wrapper { + flex-direction: row-reverse; + } + + [dir='rtl'] .affine-list-block__numbered { + margin-left: 0; + margin-right: 2px; + } + + [dir='rtl'] .affine-list-block__todo-prefix { + margin-left: 0; + margin-right: 2px; + } + ${listPrefix} `; diff --git a/packages/affine/blocks/paragraph/src/paragraph-block.ts b/packages/affine/blocks/paragraph/src/paragraph-block.ts index cfc2994a5af4..978b05fa8039 100644 --- a/packages/affine/blocks/paragraph/src/paragraph-block.ts +++ b/packages/affine/blocks/paragraph/src/paragraph-block.ts @@ -16,6 +16,8 @@ import { calculateCollapsedSiblings, getNearestHeadingBefore, getViewportElement, + getTextDirection, + TextDirection, } from '@blocksuite/affine-shared/utils'; import type { BlockComponent } from '@blocksuite/std'; import { TextSelection } from '@blocksuite/std'; @@ -334,6 +336,7 @@ export class ParagraphBlockComponent extends CaptionedBlockComponent getViewportElement(this.host)} > diff --git a/packages/affine/blocks/paragraph/src/styles.ts b/packages/affine/blocks/paragraph/src/styles.ts index f014595a92e0..da8d4ec7c8dd 100644 --- a/packages/affine/blocks/paragraph/src/styles.ts +++ b/packages/affine/blocks/paragraph/src/styles.ts @@ -129,6 +129,16 @@ export const paragraphBlockStyles = css` border-radius: 18px; } + /* RTL support for quote */ + [dir='rtl'] .quote { + padding-left: 0; + padding-right: 17px; + } + [dir='rtl'] .quote::after { + left: auto; + right: 0; + } + .affine-paragraph-placeholder { position: absolute; display: none; @@ -142,6 +152,12 @@ export const paragraphBlockStyles = css` color: var(--affine-black-30); fill: var(--affine-black-30); } + + /* RTL support for placeholder */ + [dir='rtl'] .affine-paragraph-placeholder { + left: auto; + right: 0; + } @media print { .affine-paragraph-placeholder { display: none !important; diff --git a/packages/affine/blocks/root/src/page/page-root-block.ts b/packages/affine/blocks/root/src/page/page-root-block.ts index e5f9b7ab4754..2494c2b07d4c 100644 --- a/packages/affine/blocks/root/src/page/page-root-block.ts +++ b/packages/affine/blocks/root/src/page/page-root-block.ts @@ -96,6 +96,15 @@ export class PageRootBlockComponent extends BlockComponent { display: block; } + /* RTL support for page root */ + [dir='rtl'] .affine-page-root-block-container { + text-align: right; + } + + [dir='ltr'] .affine-page-root-block-container { + text-align: left; + } + @media print { .selected { background-color: transparent !important; diff --git a/packages/affine/model/src/consts/text.ts b/packages/affine/model/src/consts/text.ts index ebe2f3f55b05..6f9df7ffb9f2 100644 --- a/packages/affine/model/src/consts/text.ts +++ b/packages/affine/model/src/consts/text.ts @@ -7,6 +7,7 @@ export enum TextAlign { Center = 'center', Left = 'left', Right = 'right', + Justify = 'justify', } export const TextAlignMap = createEnumMap(TextAlign); @@ -24,6 +25,7 @@ export type TextStyleProps = { fontStyle: FontStyle; fontWeight: FontWeight; textAlign: TextAlign; + textDirection?: TextDirection; }; export enum FontWeight { @@ -62,7 +64,14 @@ export enum TextResizing { AUTO_HEIGHT, } +export enum TextDirection { + LTR = 'ltr', + RTL = 'rtl', + Auto = 'auto', +} + export const FontFamilySchema = z.nativeEnum(FontFamily); export const FontWeightSchema = z.nativeEnum(FontWeight); export const FontStyleSchema = z.nativeEnum(FontStyle); export const TextAlignSchema = z.nativeEnum(TextAlign); +export const TextDirectionSchema = z.nativeEnum(TextDirection); diff --git a/packages/affine/rich-text/src/rich-text.ts b/packages/affine/rich-text/src/rich-text.ts index 0e6e18bdb3aa..b7bbecddfb1c 100644 --- a/packages/affine/rich-text/src/rich-text.ts +++ b/packages/affine/rich-text/src/rich-text.ts @@ -2,6 +2,12 @@ import type { AffineInlineEditor, AffineTextAttributes, } from '@blocksuite/affine-shared/types'; +import { + getTextDirection, + getRTLTextAlignCSS, + TextDirection, + isRTL +} from '@blocksuite/affine-shared/utils'; import { WithDisposable } from '@blocksuite/global/lit'; import { ShadowlessElement } from '@blocksuite/std'; import { @@ -56,11 +62,53 @@ export class RichText extends WithDisposable(ShadowlessElement) { rich-text .nowrap-lines v-element span { white-space: pre !important; } + + /* RTL support */ + .inline-editor[dir="rtl"] { + text-align: right; + } + + .inline-editor[dir="ltr"] { + text-align: left; + } `; #verticalScrollContainer: HTMLElement | null = null; private readonly _inlineEditor$ = signal(null); + + @property({ attribute: false }) + accessor textDirection: TextDirection = TextDirection.Auto; + + /** + * Get the current text direction based on content + */ + private getCurrentTextDirection(): TextDirection { + if (this.textDirection !== TextDirection.Auto) { + return this.textDirection; + } + + const inlineEditor = this.inlineEditor; + if (!inlineEditor) return TextDirection.LTR; + + const text = inlineEditor.yTextString; + return getTextDirection(text); + } + + /** + * Update the direction attribute on the inline editor + */ + private updateTextDirection() { + const inlineEditor = this.inlineEditor; + if (!inlineEditor) return; + + const direction = this.getCurrentTextDirection(); + const directionValue = direction === TextDirection.RTL ? 'rtl' : 'ltr'; + + if (inlineEditor.rootElement) { + inlineEditor.rootElement.setAttribute('dir', directionValue); + } + } private readonly _onCopy = (e: ClipboardEvent) => { const inlineEditor = this.inlineEditor; @@ -399,9 +447,13 @@ export class RichText extends WithDisposable(ShadowlessElement) { readonly: this.readonly, }); + const direction = this.getCurrentTextDirection(); + const directionValue = direction === TextDirection.RTL ? 'rtl' : 'ltr'; + return html`
`; } diff --git a/packages/affine/shared/src/styles/index.ts b/packages/affine/shared/src/styles/index.ts index 8af751d085b5..14d98bca17ea 100644 --- a/packages/affine/shared/src/styles/index.ts +++ b/packages/affine/shared/src/styles/index.ts @@ -1,4 +1,5 @@ export { fontBaseStyle, fontSMStyle, fontXSStyle } from './font'; export { panelBaseColorsStyle, panelBaseStyle } from './panel'; +export { rtlStyles, rtlCSSVariables } from './rtl'; export { scrollbarStyle } from './scrollbar-style'; export { affineTextStyles } from './text'; diff --git a/packages/affine/shared/src/styles/rtl.ts b/packages/affine/shared/src/styles/rtl.ts new file mode 100644 index 000000000000..dbc47e39386f --- /dev/null +++ b/packages/affine/shared/src/styles/rtl.ts @@ -0,0 +1,452 @@ +import { css } from 'lit'; + +/** + * RTL CSS utilities for BlockSuite + * Provides CSS classes and utilities for RTL support + */ + +/** + * RTL-aware text alignment utilities + */ +export const rtlTextAlignStyles = css` + .rtl-text-left { + text-align: left; + } + + .rtl-text-right { + text-align: right; + } + + .rtl-text-center { + text-align: center; + } + + .rtl-text-justify { + text-align: justify; + } + + /* RTL-specific overrides */ + [dir='rtl'] .rtl-text-left { + text-align: right; + } + + [dir='rtl'] .rtl-text-right { + text-align: left; + } + + [dir='rtl'] .rtl-text-center { + text-align: center; + } + + [dir='rtl'] .rtl-text-justify { + text-align: justify; + } +`; + +/** + * RTL-aware margin utilities + */ +export const rtlMarginStyles = css` + .rtl-ml-auto { + margin-left: auto; + } + + .rtl-mr-auto { + margin-right: auto; + } + + .rtl-mx-auto { + margin-left: auto; + margin-right: auto; + } + + /* RTL-specific overrides */ + [dir='rtl'] .rtl-ml-auto { + margin-left: 0; + margin-right: auto; + } + + [dir='rtl'] .rtl-mr-auto { + margin-right: 0; + margin-left: auto; + } + + [dir='rtl'] .rtl-mx-auto { + margin-left: auto; + margin-right: auto; + } +`; + +/** + * RTL-aware padding utilities + */ +export const rtlPaddingStyles = css` + .rtl-pl-0 { + padding-left: 0; + } + + .rtl-pr-0 { + padding-right: 0; + } + + .rtl-px-0 { + padding-left: 0; + padding-right: 0; + } + + .rtl-pl-1 { + padding-left: 4px; + } + + .rtl-pr-1 { + padding-right: 4px; + } + + .rtl-px-1 { + padding-left: 4px; + padding-right: 4px; + } + + .rtl-pl-2 { + padding-left: 8px; + } + + .rtl-pr-2 { + padding-right: 8px; + } + + .rtl-px-2 { + padding-left: 8px; + padding-right: 8px; + } + + .rtl-pl-3 { + padding-left: 12px; + } + + .rtl-pr-3 { + padding-right: 12px; + } + + .rtl-px-3 { + padding-left: 12px; + padding-right: 12px; + } + + .rtl-pl-4 { + padding-left: 16px; + } + + .rtl-pr-4 { + padding-right: 16px; + } + + .rtl-px-4 { + padding-left: 16px; + padding-right: 16px; + } + + /* RTL-specific overrides */ + [dir='rtl'] .rtl-pl-0 { + padding-left: 0; + padding-right: 0; + } + + [dir='rtl'] .rtl-pr-0 { + padding-right: 0; + padding-left: 0; + } + + [dir='rtl'] .rtl-px-0 { + padding-left: 0; + padding-right: 0; + } + + [dir='rtl'] .rtl-pl-1 { + padding-left: 0; + padding-right: 4px; + } + + [dir='rtl'] .rtl-pr-1 { + padding-right: 0; + padding-left: 4px; + } + + [dir='rtl'] .rtl-px-1 { + padding-left: 4px; + padding-right: 4px; + } + + [dir='rtl'] .rtl-pl-2 { + padding-left: 0; + padding-right: 8px; + } + + [dir='rtl'] .rtl-pr-2 { + padding-right: 0; + padding-left: 8px; + } + + [dir='rtl'] .rtl-px-2 { + padding-left: 8px; + padding-right: 8px; + } + + [dir='rtl'] .rtl-pl-3 { + padding-left: 0; + padding-right: 12px; + } + + [dir='rtl'] .rtl-pr-3 { + padding-right: 0; + padding-left: 12px; + } + + [dir='rtl'] .rtl-px-3 { + padding-left: 12px; + padding-right: 12px; + } + + [dir='rtl'] .rtl-pl-4 { + padding-left: 0; + padding-right: 16px; + } + + [dir='rtl'] .rtl-pr-4 { + padding-right: 0; + padding-left: 16px; + } + + [dir='rtl'] .rtl-px-4 { + padding-left: 16px; + padding-right: 16px; + } +`; + +/** + * RTL-aware border utilities + */ +export const rtlBorderStyles = css` + .rtl-border-l { + border-left: 1px solid var(--affine-border-color); + } + + .rtl-border-r { + border-right: 1px solid var(--affine-border-color); + } + + .rtl-border-x { + border-left: 1px solid var(--affine-border-color); + border-right: 1px solid var(--affine-border-color); + } + + /* RTL-specific overrides */ + [dir='rtl'] .rtl-border-l { + border-left: none; + border-right: 1px solid var(--affine-border-color); + } + + [dir='rtl'] .rtl-border-r { + border-right: none; + border-left: 1px solid var(--affine-border-color); + } + + [dir='rtl'] .rtl-border-x { + border-left: 1px solid var(--affine-border-color); + border-right: 1px solid var(--affine-border-color); + } +`; + +/** + * RTL-aware positioning utilities + */ +export const rtlPositionStyles = css` + .rtl-left-0 { + left: 0; + } + + .rtl-right-0 { + right: 0; + } + + .rtl-inset-x-0 { + left: 0; + right: 0; + } + + /* RTL-specific overrides */ + [dir='rtl'] .rtl-left-0 { + left: auto; + right: 0; + } + + [dir='rtl'] .rtl-right-0 { + right: auto; + left: 0; + } + + [dir='rtl'] .rtl-inset-x-0 { + left: 0; + right: 0; + } +`; + +/** + * RTL-aware flexbox utilities + */ +export const rtlFlexStyles = css` + .rtl-flex-row { + flex-direction: row; + } + + .rtl-flex-row-reverse { + flex-direction: row-reverse; + } + + .rtl-justify-start { + justify-content: flex-start; + } + + .rtl-justify-end { + justify-content: flex-end; + } + + .rtl-justify-center { + justify-content: center; + } + + .rtl-justify-between { + justify-content: space-between; + } + + .rtl-items-start { + align-items: flex-start; + } + + .rtl-items-end { + align-items: flex-end; + } + + .rtl-items-center { + align-items: center; + } + + /* RTL-specific overrides */ + [dir='rtl'] .rtl-flex-row { + flex-direction: row-reverse; + } + + [dir='rtl'] .rtl-flex-row-reverse { + flex-direction: row; + } + + [dir='rtl'] .rtl-justify-start { + justify-content: flex-end; + } + + [dir='rtl'] .rtl-justify-end { + justify-content: flex-start; + } + + [dir='rtl'] .rtl-justify-center { + justify-content: center; + } + + [dir='rtl'] .rtl-justify-between { + justify-content: space-between; + } +`; + +/** + * RTL-aware text direction utilities + */ +export const rtlDirectionStyles = css` + .rtl-dir-ltr { + direction: ltr; + } + + .rtl-dir-rtl { + direction: rtl; + } + + .rtl-dir-auto { + direction: auto; + } +`; + +/** + * RTL-aware text overflow utilities + */ +export const rtlTextOverflowStyles = css` + .rtl-text-ellipsis { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .rtl-text-ellipsis-start { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + direction: rtl; + text-align: right; + } + + .rtl-text-ellipsis-end { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + direction: ltr; + text-align: left; + } +`; + +/** + * Combined RTL styles + */ +export const rtlStyles = css` + ${rtlTextAlignStyles} + ${rtlMarginStyles} + ${rtlPaddingStyles} + ${rtlBorderStyles} + ${rtlPositionStyles} + ${rtlFlexStyles} + ${rtlDirectionStyles} + ${rtlTextOverflowStyles} +`; + +/** + * RTL CSS variables + */ +export const rtlCSSVariables = css` + :root { + /* RTL spacing variables */ + --rtl-spacing-xs: 2px; + --rtl-spacing-sm: 4px; + --rtl-spacing-md: 8px; + --rtl-spacing-lg: 12px; + --rtl-spacing-xl: 16px; + --rtl-spacing-2xl: 24px; + --rtl-spacing-3xl: 32px; + + /* RTL border radius variables */ + --rtl-border-radius-sm: 2px; + --rtl-border-radius-md: 4px; + --rtl-border-radius-lg: 8px; + --rtl-border-radius-xl: 12px; + + /* RTL shadow variables */ + --rtl-shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + --rtl-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + --rtl-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1); + } + + /* RTL-specific CSS variables */ + [dir='rtl'] { + --rtl-mirror-scale-x: -1; + } + + [dir='ltr'] { + --rtl-mirror-scale-x: 1; + } +`; diff --git a/packages/affine/shared/src/utils/index.ts b/packages/affine/shared/src/utils/index.ts index 09f88adf2750..7fa5e17efed1 100644 --- a/packages/affine/shared/src/utils/index.ts +++ b/packages/affine/shared/src/utils/index.ts @@ -19,6 +19,7 @@ export * from './popper-position'; export * from './print-to-pdf'; export * from './reference'; export * from './reordering'; +export * from './rtl'; export * from './signal'; export * from './string'; export * from './title'; diff --git a/packages/affine/shared/src/utils/rtl.ts b/packages/affine/shared/src/utils/rtl.ts new file mode 100644 index 000000000000..32c7f97d052b --- /dev/null +++ b/packages/affine/shared/src/utils/rtl.ts @@ -0,0 +1,258 @@ +/** + * RTL (Right-to-Left) utilities for BlockSuite + * Provides text direction detection, alignment conversion, and RTL-aware utilities + */ + +/** + * Character ranges for RTL detection + * Based on Unicode bidirectional algorithm + */ +const RS_LTR_CHARS = + 'A-Za-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02B8\u0300-\u0590\u0800-\u1FFF' + + '\u2C00-\uFB1C\uFDFE-\uFE6F\uFEFD-\uFFFF'; +const RS_RTL_CHARS = '\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC'; + +/** + * Regular expression to detect RTL text + */ +const RE_RTL_CHECK = new RegExp(`^[^${RS_LTR_CHARS}]*[${RS_RTL_CHARS}]`); + +/** + * Text alignment options + */ +export enum TextAlign { + Left = 'left', + Center = 'center', + Right = 'right', + Justify = 'justify', +} + +/** + * Text direction options + */ +export enum TextDirection { + LTR = 'ltr', + RTL = 'rtl', + Auto = 'auto', +} + +/** + * RTL-aware text alignment mapping + */ +export const RTL_ALIGNMENT_MAP: Record> = { + [TextAlign.Left]: { + [TextDirection.LTR]: TextAlign.Left, + [TextDirection.RTL]: TextAlign.Right, + [TextDirection.Auto]: TextAlign.Left, + }, + [TextAlign.Right]: { + [TextDirection.LTR]: TextAlign.Right, + [TextDirection.RTL]: TextAlign.Left, + [TextDirection.Auto]: TextAlign.Right, + }, + [TextAlign.Center]: { + [TextDirection.LTR]: TextAlign.Center, + [TextDirection.RTL]: TextAlign.Center, + [TextDirection.Auto]: TextAlign.Center, + }, + [TextAlign.Justify]: { + [TextDirection.LTR]: TextAlign.Justify, + [TextDirection.RTL]: TextAlign.Justify, + [TextDirection.Auto]: TextAlign.Justify, + }, +}; + +/** + * Detects if text contains RTL characters + * @param text - The text to analyze + * @returns true if text contains RTL characters + */ +export function isRTL(text: string): boolean { + if (!text || text.length === 0) return false; + return RE_RTL_CHECK.test(text); +} + +/** + * Detects the text direction based on the first character + * @param text - The text to analyze + * @returns The text direction based on the first character + */ +export function getTextDirection(text: string): TextDirection { + if (!text || text.length === 0) return TextDirection.LTR; + + // Check if the first non-whitespace character is RTL + const trimmedText = text.trim(); + if (trimmedText.length === 0) return TextDirection.LTR; + + const firstChar = trimmedText[0]; + return isRTL(firstChar) ? TextDirection.RTL : TextDirection.LTR; +} + +/** + * Converts text alignment to RTL-aware alignment + * @param alignment - The original text alignment + * @param direction - The text direction + * @returns RTL-aware text alignment + */ +export function getRTLAlignment( + alignment: TextAlign, + direction: TextDirection +): TextAlign { + return RTL_ALIGNMENT_MAP[alignment][direction]; +} + +/** + * Gets the CSS text-align value for RTL-aware alignment + * @param alignment - The text alignment + * @param direction - The text direction + * @returns CSS text-align value + */ +export function getRTLTextAlign( + alignment: TextAlign, + direction: TextDirection +): string { + const rtlAlignment = getRTLAlignment(alignment, direction); + return rtlAlignment; +} + +/** + * Gets the CSS direction value + * @param direction - The text direction + * @returns CSS direction value + */ +export function getCSSDirection(direction: TextDirection): string { + return direction === TextDirection.RTL ? 'rtl' : 'ltr'; +} + +/** + * Checks if a text alignment should be mirrored in RTL + * @param alignment - The text alignment + * @returns true if alignment should be mirrored + */ +export function shouldMirrorAlignment(alignment: TextAlign): boolean { + return alignment === TextAlign.Left || alignment === TextAlign.Right; +} + +/** + * Gets the mirrored text alignment for RTL + * @param alignment - The original text alignment + * @returns Mirrored text alignment + */ +export function getMirroredAlignment(alignment: TextAlign): TextAlign { + switch (alignment) { + case TextAlign.Left: + return TextAlign.Right; + case TextAlign.Right: + return TextAlign.Left; + case TextAlign.Center: + case TextAlign.Justify: + return alignment; + default: + return alignment; + } +} + +/** + * Creates RTL-aware CSS properties for text alignment + * @param alignment - The text alignment + * @param direction - The text direction + * @returns CSS properties object + */ +export function getRTLTextAlignCSS( + alignment: TextAlign, + direction: TextDirection +): Record { + const rtlAlignment = getRTLAlignment(alignment, direction); + const cssDirection = getCSSDirection(direction); + + return { + 'text-align': rtlAlignment, + 'direction': cssDirection, + }; +} + +/** + * Detects if text contains mixed LTR/RTL content + * @param text - The text to analyze + * @returns true if text contains both LTR and RTL characters + */ +export function isMixedDirection(text: string): boolean { + if (!text || text.length === 0) return false; + + const hasRTL = RE_RTL_CHECK.test(text); + const hasLTR = new RegExp(`[${RS_LTR_CHARS}]`).test(text); + + return hasRTL && hasLTR; +} + +/** + * Splits text into directionally uniform segments + * @param text - The text to segment + * @returns Array of text segments with their directions + */ +export function segmentByDirection(text: string): Array<{ + text: string; + direction: TextDirection; +}> { + if (!text || text.length === 0) return []; + + const segments: Array<{ text: string; direction: TextDirection }> = []; + let currentSegment = ''; + let currentDirection: TextDirection | null = null; + + for (let i = 0; i < text.length; i++) { + const char = text[i]; + const charDirection = isRTL(char) ? TextDirection.RTL : TextDirection.LTR; + + if (currentDirection === null) { + currentDirection = charDirection; + currentSegment = char; + } else if (currentDirection === charDirection) { + currentSegment += char; + } else { + // Direction changed, push current segment and start new one + segments.push({ + text: currentSegment, + direction: currentDirection, + }); + currentSegment = char; + currentDirection = charDirection; + } + } + + // Push the last segment + if (currentSegment.length > 0 && currentDirection !== null) { + segments.push({ + text: currentSegment, + direction: currentDirection, + }); + } + + return segments; +} + +/** + * RTL-aware text truncation + * @param text - The text to truncate + * @param maxLength - Maximum length + * @param direction - Text direction + * @returns Truncated text with appropriate ellipsis + */ +export function truncateRTLText( + text: string, + maxLength: number, + direction: TextDirection = TextDirection.Auto +): string { + if (text.length <= maxLength) return text; + + const actualDirection = direction === TextDirection.Auto + ? getTextDirection(text) + : direction; + + const ellipsis = actualDirection === TextDirection.RTL ? '...' : '...'; + const truncated = text.slice(0, maxLength - ellipsis.length); + + return actualDirection === TextDirection.RTL + ? ellipsis + truncated + : truncated + ellipsis; +} diff --git a/packages/playground/apps/starter/data/index.ts b/packages/playground/apps/starter/data/index.ts index fe7dc54dfeae..6a18ca1fdbb6 100644 --- a/packages/playground/apps/starter/data/index.ts +++ b/packages/playground/apps/starter/data/index.ts @@ -14,6 +14,7 @@ export * from './linked.js'; export * from './multiple-editor.js'; export * from './pending-structs.js'; export * from './preset.js'; +export * from './rtl-test.js'; export * from './synced.js'; export type { InitFn } from './utils.js'; export * from './version-mismatch.js'; diff --git a/packages/playground/apps/starter/data/rtl-test.ts b/packages/playground/apps/starter/data/rtl-test.ts new file mode 100644 index 000000000000..8e58fd012626 --- /dev/null +++ b/packages/playground/apps/starter/data/rtl-test.ts @@ -0,0 +1,118 @@ +import { Text, type Workspace } from '@blocksuite/affine/store'; + +import type { InitFn } from './utils.js'; + +export const rtlTest: InitFn = (collection: Workspace, id: string) => { + const doc = collection.getDoc(id) ?? collection.createDoc(id); + const store = doc.getStore(); + doc.clear(); + + doc.load(() => { + // Add root block and surface block at root level + const rootId = store.addBlock('affine:page', { + title: new Text('تست RTL - BlockSuite'), + }); + + store.addBlock('affine:surface', {}, rootId); + + // Add note block inside root block + const noteId = store.addBlock('affine:note', {}, rootId); + + // Add a heading with Persian text + store.addBlock('affine:paragraph', { + type: 'h1', + text: new Text([ + { + insert: 'خوش آمدید به BlockSuite', + attributes: {}, + }, + ]), + }, noteId); + + // Add a paragraph with mixed LTR/RTL text + store.addBlock('affine:paragraph', { + type: 'text', + text: new Text([ + { + insert: 'سلام - این یک تست از پشتیبانی RTL در BlockSuite است. ', + attributes: {}, + }, + { + insert: 'Hello World', + attributes: { bold: true }, + }, + ]), + }, noteId); + + // Add a quote with Persian text + store.addBlock('affine:paragraph', { + type: 'quote', + text: new Text([ + { + insert: 'این یک نقل قول فارسی برای تست پشتیبانی RTL در BlockSuite است', + attributes: {}, + }, + ]), + }, noteId); + + // Add a list with Persian items + const listId = store.addBlock('affine:list', { + type: 'bulleted', + text: new Text([ + { + insert: 'عنصر لیست به زبان فارسی', + attributes: {}, + }, + ]), + }, noteId); + + store.addBlock('affine:paragraph', { + type: 'text', + text: new Text([ + { + insert: 'متن فرعی در لیست', + attributes: {}, + }, + ]), + }, listId); + + store.addBlock('affine:paragraph', { + type: 'text', + text: new Text([ + { + insert: 'عنصر دیگر با متن ترکیبی Mixed Text', + attributes: {}, + }, + ]), + }, listId); + + // Add a code block with Persian comments + store.addBlock('affine:code', { + language: 'javascript', + text: new Text([ + { + insert: '// کامنت فارسی\n', + attributes: {}, + }, + { + insert: 'function salam() {\n', + attributes: {}, + }, + { + insert: ' console.log("سلام دنیا");\n', + attributes: {}, + }, + { + insert: '}', + attributes: {}, + }, + ]), + }, noteId); + }); + + store.resetHistory(); +}; + +rtlTest.id = 'rtl-test'; +rtlTest.displayName = 'RTL Test'; +rtlTest.description = 'Test RTL (Right-to-Left) text support with Persian (Farsi), Hebrew, and mixed content';