diff --git a/packages/roosterjs-content-model-dom/lib/formatHandlers/block/directionFormatHandler.ts b/packages/roosterjs-content-model-dom/lib/formatHandlers/block/directionFormatHandler.ts index fdcecdd2bfec..55603d3ae997 100644 --- a/packages/roosterjs-content-model-dom/lib/formatHandlers/block/directionFormatHandler.ts +++ b/packages/roosterjs-content-model-dom/lib/formatHandlers/block/directionFormatHandler.ts @@ -13,12 +13,16 @@ export const directionFormatHandler: FormatHandler = { format.direction = dir == 'rtl' ? 'rtl' : 'ltr'; } }, - apply: (format, element) => { + apply: (format, element, context) => { if (format.direction) { element.style.direction = format.direction; } - if (format.direction == 'rtl' && isElementOfType(element, 'table')) { + if ( + format.direction == 'rtl' && + isElementOfType(element, 'table') && + context.implicitFormat.direction != 'rtl' + ) { element.style.justifySelf = 'flex-end'; } }, diff --git a/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleFormatContainer.ts b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleFormatContainer.ts index da761c33f25d..d6895af24458 100644 --- a/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleFormatContainer.ts +++ b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleFormatContainer.ts @@ -53,13 +53,29 @@ export const handleFormatContainer: ContentModelBlockHandler { - context.modelHandlers.blockGroupChildren(doc, containerNode, container, context); - }); - } else { - context.modelHandlers.blockGroupChildren(doc, containerNode, container, context); - } + stackFormat( + context, + container.format.direction ? { direction: container.format.direction } : null, + () => { + if (container.tagName == 'pre') { + stackFormat(context, PreChildFormat, () => { + context.modelHandlers.blockGroupChildren( + doc, + containerNode, + container, + context + ); + }); + } else { + context.modelHandlers.blockGroupChildren( + doc, + containerNode, + container, + context + ); + } + } + ); element = containerNode; } diff --git a/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleListItem.ts b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleListItem.ts index 1d6d51e42f43..16e4901d7212 100644 --- a/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleListItem.ts +++ b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleListItem.ts @@ -76,7 +76,13 @@ export const handleListItem: ContentModelBlockHandler = ( applyFormat(li, context.formatAppliers.listItemElement, listItem.format, context); stackFormat(context, listItem.formatHolder.format, () => { - context.modelHandlers.blockGroupChildren(doc, li, listItem, context); + stackFormat( + context, + listItem.format.direction ? { direction: listItem.format.direction } : null, + () => { + context.modelHandlers.blockGroupChildren(doc, li, listItem, context); + } + ); }); } else { // There is no level for this list item, that means it should be moved out of the list diff --git a/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleTable.ts b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleTable.ts index 2f183cf580b2..57f5c24f434c 100644 --- a/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleTable.ts +++ b/packages/roosterjs-content-model-dom/lib/modelToDom/handlers/handleTable.ts @@ -150,7 +150,13 @@ export const handleTable: ContentModelBlockHandler = ( applyFormat(td, context.formatAppliers.dataset, cell.dataset, context); } - context.modelHandlers.blockGroupChildren(doc, td, cell, context); + stackFormat( + context, + cell.format.direction ? { direction: cell.format.direction } : null, + () => { + context.modelHandlers.blockGroupChildren(doc, td, cell, context); + } + ); }); context.onNodeCreated?.(cell, td); diff --git a/packages/roosterjs-content-model-dom/test/endToEndTest.ts b/packages/roosterjs-content-model-dom/test/endToEndTest.ts index 8d55177d6929..91a11f330c2b 100644 --- a/packages/roosterjs-content-model-dom/test/endToEndTest.ts +++ b/packages/roosterjs-content-model-dom/test/endToEndTest.ts @@ -567,6 +567,249 @@ describe('End to end test for DOM => Model => DOM/TEXT', () => { ); }); + it('LTR table under RTL table', () => { + runTest( + '
bb
', + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + format: {}, + height: 0, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Table', + rows: [ + { + format: {}, + height: 0, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'bb', + format: {}, + }, + ], + format: { + direction: 'ltr', + }, + isImplicit: true, + }, + ], + format: { + direction: 'ltr', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: { + direction: 'ltr', + }, + widths: [], + dataset: {}, + }, + ], + format: { + direction: 'rtl', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: { direction: 'rtl' }, + widths: [], + dataset: {}, + }, + ], + }, + 'bb', + '
bb
' + ); + }); + + it('RTL table under LTR table', () => { + runTest( + '
bb
', + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + format: {}, + height: 0, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Table', + rows: [ + { + format: {}, + height: 0, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'bb', + format: {}, + }, + ], + format: { + direction: 'rtl', + }, + isImplicit: true, + }, + ], + format: { + direction: 'rtl', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: { + direction: 'rtl', + }, + widths: [], + dataset: {}, + }, + ], + format: { + direction: 'ltr', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: { direction: 'ltr' }, + widths: [], + dataset: {}, + }, + ], + }, + 'bb', + '
bb
' + ); + }); + + it('RTL table under RTL table', () => { + runTest( + '
bb
', + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + format: {}, + height: 0, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Table', + rows: [ + { + format: {}, + height: 0, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'bb', + format: {}, + }, + ], + format: { + direction: 'rtl', + }, + isImplicit: true, + }, + ], + format: { + direction: 'rtl', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: { + direction: 'rtl', + }, + widths: [], + dataset: {}, + }, + ], + format: { + direction: 'rtl', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: { direction: 'rtl' }, + widths: [], + dataset: {}, + }, + ], + }, + 'bb', + '
bb
' + ); + }); + it('Table under styled block', () => { runTest( 'aa
bb
cc
', diff --git a/packages/roosterjs-content-model-dom/test/formatHandlers/block/directionFormatHandlerTest.ts b/packages/roosterjs-content-model-dom/test/formatHandlers/block/directionFormatHandlerTest.ts index 23f1e9d03af0..ab1b96b19c95 100644 --- a/packages/roosterjs-content-model-dom/test/formatHandlers/block/directionFormatHandlerTest.ts +++ b/packages/roosterjs-content-model-dom/test/formatHandlers/block/directionFormatHandlerTest.ts @@ -90,4 +90,29 @@ describe('directionFormatHandler.apply', () => { '
' ); }); + + it('RTL on table, parent implicit direction is LTR, applies justify-self', () => { + const table = document.createElement('table'); + format.direction = 'rtl'; + context.implicitFormat.direction = 'ltr'; + directionFormatHandler.apply(format, table, context); + expect(table.outerHTML).toBe( + '
' + ); + }); + + it('RTL on table, parent implicit direction is RTL, skips justify-self', () => { + const table = document.createElement('table'); + format.direction = 'rtl'; + context.implicitFormat.direction = 'rtl'; + directionFormatHandler.apply(format, table, context); + expect(table.outerHTML).toBe('
'); + }); + + it('RTL on non-table element, never applies justify-self', () => { + const td = document.createElement('td'); + format.direction = 'rtl'; + directionFormatHandler.apply(format, td, context); + expect(td.outerHTML).toBe(''); + }); }); diff --git a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleFormatContainerTest.ts b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleFormatContainerTest.ts index d03a267af130..48ce9128e70e 100644 --- a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleFormatContainerTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleFormatContainerTest.ts @@ -110,6 +110,63 @@ describe('handleFormatContainer', () => { }); }); + it('RTL container propagates direction into implicitFormat for children', () => { + const parent = document.createElement('div'); + const container = createFormatContainer('div', { direction: 'rtl' }); + const paragraph = createParagraph(); + container.blocks.push(paragraph); + + let capturedDirection: string | undefined; + handleBlockGroupChildren.and.callFake((_doc, _node, _group, ctx) => { + capturedDirection = ctx.implicitFormat.direction; + }); + + handleFormatContainer(document, parent, container, context, null); + + expect(capturedDirection).toBe('rtl'); + // implicitFormat must be restored after stackFormat + expect(context.implicitFormat.direction).toBeUndefined(); + }); + + it('Container without direction does not change implicitFormat for children', () => { + const parent = document.createElement('div'); + const container = createFormatContainer('div'); + const paragraph = createParagraph(); + container.blocks.push(paragraph); + + let capturedDirection: string | undefined; + context.implicitFormat.direction = 'rtl'; + handleBlockGroupChildren.and.callFake((_doc, _node, _group, ctx) => { + capturedDirection = ctx.implicitFormat.direction; + }); + + handleFormatContainer(document, parent, container, context, null); + + expect(capturedDirection).toBe('rtl'); + }); + + it('Pre container with RTL direction propagates both pre format and direction', () => { + const parent = document.createElement('div'); + const container = createFormatContainer('pre', { direction: 'rtl' }); + const paragraph = createParagraph(); + paragraph.segments.push(createText('test')); + container.blocks.push(paragraph); + + let capturedDirection: string | undefined; + let capturedWhiteSpace: string | undefined; + handleBlockGroupChildren.and.callFake((_doc, _node, _group, ctx) => { + capturedDirection = ctx.implicitFormat.direction; + capturedWhiteSpace = ctx.implicitFormat.whiteSpace; + }); + + handleFormatContainer(document, parent, container, context, null); + + expect(capturedDirection).toBe('rtl'); + expect(capturedWhiteSpace).toBe('pre'); + expect(context.implicitFormat.direction).toBeUndefined(); + expect(context.implicitFormat.whiteSpace).toBeUndefined(); + }); + it('With onNodeCreated', () => { const parent = document.createElement('div'); const quote = createFormatContainer('blockquote'); diff --git a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleListItemTest.ts b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleListItemTest.ts index 1d31b5764bf2..324e6b915c07 100644 --- a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleListItemTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleListItemTest.ts @@ -436,6 +436,40 @@ describe('handleListItem without format handler', () => { '
  1. test1
    test2
    test3
    test4
' ); }); + + it('List item with RTL direction propagates direction into implicitFormat for children', () => { + const parent = document.createElement('div'); + const listItem = createListItem([createListLevel('OL')]); + listItem.format.direction = 'rtl'; + listItem.blocks.push(createParagraph()); + + let capturedDirection: string | undefined; + handleBlockGroupChildrenSpy.and.callFake((_doc, _node, _group, ctx) => { + capturedDirection = ctx.implicitFormat.direction; + }); + + handleListItem(document, parent, listItem, context, null); + + expect(capturedDirection).toBe('rtl'); + // implicitFormat must be restored after stackFormat + expect(context.implicitFormat.direction).toBeUndefined(); + }); + + it('List item without direction does not change implicitFormat for children', () => { + const parent = document.createElement('div'); + const listItem = createListItem([createListLevel('OL')]); + listItem.blocks.push(createParagraph()); + + let capturedDirection: string | undefined; + context.implicitFormat.direction = 'rtl'; + handleBlockGroupChildrenSpy.and.callFake((_doc, _node, _group, ctx) => { + capturedDirection = ctx.implicitFormat.direction; + }); + + handleListItem(document, parent, listItem, context, null); + + expect(capturedDirection).toBe('rtl'); + }); }); describe('handleListItem with cache', () => { diff --git a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts index dd01a58e61f7..bb89ee995695 100644 --- a/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts @@ -660,6 +660,72 @@ describe('handleTable', () => { ); }); + it('Cell with RTL direction propagates direction into implicitFormat for children', () => { + const parent = document.createElement('div'); + const table = createTable(1); + const cell = createTableCell(false, false, false, { direction: 'rtl' }); + table.rows[0].cells.push(cell); + + let capturedDirection: string | undefined; + context.modelHandlers.blockGroupChildren = jasmine + .createSpy('blockGroupChildren') + .and.callFake( + (_doc: Document, _node: Node, _group: unknown, ctx: ModelToDomContext) => { + capturedDirection = ctx.implicitFormat.direction; + } + ); + + handleTable(document, parent, table, context, null); + + expect(capturedDirection).toBe('rtl'); + // implicitFormat must be restored after stackFormat + expect(context.implicitFormat.direction).toBeUndefined(); + }); + + it('Cell with LTR direction propagates direction into implicitFormat for children', () => { + const parent = document.createElement('div'); + const table = createTable(1); + const cell = createTableCell(false, false, false, { direction: 'ltr' }); + table.rows[0].cells.push(cell); + + let capturedDirection: string | undefined; + context.implicitFormat.direction = 'rtl'; + context.modelHandlers.blockGroupChildren = jasmine + .createSpy('blockGroupChildren') + .and.callFake( + (_doc: Document, _node: Node, _group: unknown, ctx: ModelToDomContext) => { + capturedDirection = ctx.implicitFormat.direction; + } + ); + + handleTable(document, parent, table, context, null); + + expect(capturedDirection).toBe('ltr'); + // implicitFormat must be restored + expect(context.implicitFormat.direction).toBe('rtl'); + }); + + it('Cell without direction does not change implicitFormat for children', () => { + const parent = document.createElement('div'); + const table = createTable(1); + const cell = createTableCell(); + table.rows[0].cells.push(cell); + + let capturedDirection: string | undefined; + context.implicitFormat.direction = 'rtl'; + context.modelHandlers.blockGroupChildren = jasmine + .createSpy('blockGroupChildren') + .and.callFake( + (_doc: Document, _node: Node, _group: unknown, ctx: ModelToDomContext) => { + capturedDirection = ctx.implicitFormat.direction; + } + ); + + handleTable(document, parent, table, context, null); + + expect(capturedDirection).toBe('rtl'); + }); + it('handleTable without cache', () => { const parent = document.createElement('div'); const tableModel = createTable(1);