From 66fe33d6ff18dcb72c335be6cba89d2e3a0ab76c Mon Sep 17 00:00:00 2001 From: Artem Nistuley Date: Mon, 11 May 2026 20:08:59 +0300 Subject: [PATCH 1/4] fix: align rtl toolbar state and alignment writes with ooxml bidi --- .../v1/core/helpers/paragraph-alignment.js | 33 ++++ .../plan-engine/executor.test.ts | 109 ++++++++++++ .../plan-engine/executor.ts | 23 ++- .../plan-engine/paragraphs-wrappers.test.ts | 40 ++++- .../plan-engine/paragraphs-wrappers.ts | 27 +-- .../v1/extensions/text-align/text-align.js | 29 +++- .../extensions/text-align/text-align.test.js | 158 ++++++++++++++++++ .../src/headless-toolbar/helpers/paragraph.ts | 12 +- .../headless-toolbar/toolbar-registry.test.ts | 131 +++++++++++++++ 9 files changed, 532 insertions(+), 30 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/core/helpers/paragraph-alignment.js create mode 100644 packages/super-editor/src/editors/v1/extensions/text-align/text-align.test.js diff --git a/packages/super-editor/src/editors/v1/core/helpers/paragraph-alignment.js b/packages/super-editor/src/editors/v1/core/helpers/paragraph-alignment.js new file mode 100644 index 0000000000..3a10964fd4 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/helpers/paragraph-alignment.js @@ -0,0 +1,33 @@ +/** + * Maps display alignment (UI-facing physical left/right/center/justify) to + * stored OOXML paragraph justification, honoring RTL paragraph direction. + * + * @param {'left' | 'center' | 'right' | 'justify'} alignment + * @param {boolean} isRtl + * @returns {'left' | 'center' | 'right' | 'both'} + */ +export function mapDisplayAlignmentToStoredJustification(alignment, isRtl) { + if (alignment === 'justify') return 'both'; + if (!isRtl) return alignment; + if (alignment === 'left') return 'right'; + if (alignment === 'right') return 'left'; + return alignment; +} + +/** + * Maps stored OOXML paragraph justification to display alignment, honoring + * RTL paragraph direction. When justification is absent, returns the + * visual default by direction. + * + * @param {string | null | undefined} justification + * @param {boolean} isRtl + * @returns {'left' | 'center' | 'right' | 'justify'} + */ +export function mapStoredJustificationToDisplayAlignment(justification, isRtl) { + if (!justification) return isRtl ? 'right' : 'left'; + if (justification === 'both') return 'justify'; + if (!isRtl) return /** @type {'left' | 'center' | 'right' | 'justify'} */ (justification); + if (justification === 'left') return 'right'; + if (justification === 'right') return 'left'; + return /** @type {'left' | 'center' | 'right' | 'justify'} */ (justification); +} diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.test.ts index db2ddc843a..eea4510f58 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.test.ts @@ -34,6 +34,7 @@ const mockedDeps = vi.hoisted(() => ({ applyDirectMutationMeta: vi.fn(), applyTrackedMutationMeta: vi.fn(), mapBlockNodeType: vi.fn(), + calculateResolvedParagraphProperties: vi.fn((_editor: Editor, node: any) => node?.attrs?.paragraphProperties ?? {}), })); vi.mock('../helpers/index-cache.js', () => ({ @@ -71,6 +72,10 @@ vi.mock('../helpers/node-address-resolver.js', () => ({ candidate.nodeType === 'tableCell', })); +vi.mock('../../extensions/paragraph/resolvedPropertiesCache.js', () => ({ + calculateResolvedParagraphProperties: mockedDeps.calculateResolvedParagraphProperties, +})); + // Register built-in executors once beforeAll(() => { registerBuiltInExecutors(); @@ -81,6 +86,9 @@ beforeEach(() => { mockedDeps.getRevision.mockReturnValue('0'); mockedDeps.incrementRevision.mockReturnValue('1'); mockedDeps.mapBlockNodeType.mockReturnValue(undefined); + mockedDeps.calculateResolvedParagraphProperties.mockImplementation((_editor: Editor, node: any) => { + return node?.attrs?.paragraphProperties ?? {}; + }); }); // --------------------------------------------------------------------------- @@ -3015,6 +3023,107 @@ describe('executeStyleApply: collapsed-range no-op guard', () => { expect(result).toEqual({ changed: false }); expect(tr.addMark).not.toHaveBeenCalled(); }); + + it('mirrors left/right alignment for RTL paragraphs when applying format.alignment', () => { + const tr = { + setNodeMarkup: vi.fn(), + doc: { + nodesBetween: vi.fn((from: number, to: number, callback: (node: any, pos: number) => void) => { + callback( + { + isTextblock: true, + attrs: { + paragraphProperties: { + rightToLeft: true, + justification: 'left', + }, + }, + nodeSize: 8, + }, + 5, + ); + }), + }, + } as any; + + const editor = {} as Editor; + const target = makeTarget({ + op: 'style.apply' as any, + absFrom: 6, + absTo: 10, + }) as any; + const step: StyleApplyStep = { + op: 'style.apply', + id: 'step-rtl-align', + ref: 'test-ref', + args: { alignment: 'left' as any }, + }; + + const result = executeStyleApply(editor, tr, target, step, { map: (pos: number) => pos } as any); + + expect(result).toEqual({ changed: true }); + expect(tr.setNodeMarkup).toHaveBeenCalledWith( + 5, + undefined, + expect.objectContaining({ + paragraphProperties: expect.objectContaining({ + rightToLeft: true, + justification: 'right', + }), + }), + ); + }); + + it('uses resolved RTL from style cascade when applying format.alignment', () => { + mockedDeps.calculateResolvedParagraphProperties.mockReturnValueOnce({ rightToLeft: true }); + const tr = { + setNodeMarkup: vi.fn(), + doc: { + resolve: vi.fn((pos: number) => ({ pos })), + nodesBetween: vi.fn((from: number, to: number, callback: (node: any, pos: number) => void) => { + callback( + { + isTextblock: true, + attrs: { + paragraphProperties: { + rightToLeft: false, + justification: 'left', + }, + }, + nodeSize: 8, + }, + 5, + ); + }), + }, + } as any; + + const editor = {} as Editor; + const target = makeTarget({ + op: 'style.apply' as any, + absFrom: 6, + absTo: 10, + }) as any; + const step: StyleApplyStep = { + op: 'style.apply', + id: 'step-rtl-align-resolved', + ref: 'test-ref', + args: { alignment: 'left' as any }, + }; + + const result = executeStyleApply(editor, tr, target, step, { map: (pos: number) => pos } as any); + + expect(result).toEqual({ changed: true }); + expect(tr.setNodeMarkup).toHaveBeenCalledWith( + 5, + undefined, + expect.objectContaining({ + paragraphProperties: expect.objectContaining({ + justification: 'right', + }), + }), + ); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.ts index 97eacb604f..31118612e3 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/executor.ts @@ -39,7 +39,7 @@ import type { } from './executor-registry.types.js'; import { getStepExecutor } from './executor-registry.js'; import { planError } from './errors.js'; -import { ALIGNMENT_TO_JUSTIFICATION } from './paragraphs-wrappers.js'; +import { mapAlignmentToJustificationForParagraph } from './paragraphs-wrappers.js'; import { closeHistory } from 'prosemirror-history'; import { yUndoPluginKey } from 'y-prosemirror'; import { checkRevision, getRevision } from './revision-tracker.js'; @@ -53,6 +53,7 @@ import { mapBlockNodeType } from '../helpers/node-address-resolver.js'; import { resolveWithinScope, scopeByRange } from '../helpers/adapter-utils.js'; import { normalizeReplacementText } from './replacement-normalizer.js'; import { getWordChanges } from './word-diff.js'; +import { calculateResolvedParagraphProperties } from '../../extensions/paragraph/resolvedPropertiesCache.js'; import { Fragment, Slice } from 'prosemirror-model'; import type { Mark as ProseMirrorMark, MarkType, Node as ProseMirrorNode, NodeType } from 'prosemirror-model'; import type { Transaction } from 'prosemirror-state'; @@ -1067,16 +1068,19 @@ export function executeTextDelete( return { changed: true }; } -// ALIGNMENT_TO_JUSTIFICATION imported from paragraphs-wrappers.js - /** * Applies alignment to the paragraph node(s) that contain the given range. * Uses the same mechanism as paragraphsSetAlignmentWrapper: updates * paragraphProperties.justification via tr.setNodeMarkup. */ -function applyAlignmentToRange(tr: Transaction, absFrom: number, absTo: number, alignment: string): boolean { - const justification = ALIGNMENT_TO_JUSTIFICATION[alignment as keyof typeof ALIGNMENT_TO_JUSTIFICATION]; - if (!justification) return false; +function applyAlignmentToRange( + editor: Editor, + tr: Transaction, + absFrom: number, + absTo: number, + alignment: string, +): boolean { + if (!alignment) return false; let changed = false; const doc = tr.doc; @@ -1086,6 +1090,9 @@ function applyAlignmentToRange(tr: Transaction, absFrom: number, absTo: number, if (!node.isTextblock) return; const existing = (node.attrs as Record).paragraphProperties as Record | undefined; + const paragraphPos = typeof tr.doc.resolve === 'function' ? tr.doc.resolve(pos) : null; + const resolved = calculateResolvedParagraphProperties(editor, node, paragraphPos as any); + const justification = mapAlignmentToJustificationForParagraph(alignment as any, resolved?.rightToLeft === true); const currentJustification = existing?.justification; if (currentJustification === justification) return; @@ -1145,7 +1152,7 @@ export function executeStyleApply( } if (step.args.alignment) { - changed = applyAlignmentToRange(tr, absFrom, absTo, step.args.alignment) || changed; + changed = applyAlignmentToRange(editor, tr, absFrom, absTo, step.args.alignment) || changed; } return { changed }; @@ -1305,7 +1312,7 @@ export function executeSpanStyleApply( } if (step.args.alignment) { - changed = applyAlignmentToRange(tr, absFrom, absTo, step.args.alignment) || changed; + changed = applyAlignmentToRange(editor, tr, absFrom, absTo, step.args.alignment) || changed; } return { changed }; diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/paragraphs-wrappers.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/paragraphs-wrappers.test.ts index ccf0f6e73b..b69e7ef3ed 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/paragraphs-wrappers.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/paragraphs-wrappers.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import type { Editor } from '../../core/Editor.js'; +import { calculateResolvedParagraphProperties } from '../../extensions/paragraph/resolvedPropertiesCache.js'; vi.mock('./plan-wrappers.js', () => ({ executeDomainCommand: vi.fn((_editor: Editor, handler: () => boolean) => { @@ -21,7 +22,15 @@ vi.mock('./plan-wrappers.js', () => ({ }), })); -import { paragraphsSetIndentationWrapper, paragraphsSetStyleWrapper } from './paragraphs-wrappers.js'; +vi.mock('../../extensions/paragraph/resolvedPropertiesCache.js', () => ({ + calculateResolvedParagraphProperties: vi.fn((_editor, node) => node?.attrs?.paragraphProperties ?? {}), +})); + +import { + paragraphsSetIndentationWrapper, + paragraphsSetStyleWrapper, + paragraphsSetAlignmentWrapper, +} from './paragraphs-wrappers.js'; type MockNode = { type: { name: 'paragraph' | 'text' }; @@ -218,3 +227,32 @@ describe('paragraphsSetStyleWrapper', () => { expect(dispatch).not.toHaveBeenCalled(); }); }); + +describe('paragraphsSetAlignmentWrapper', () => { + it('mirrors left/right alignment for RTL paragraphs when writing justification', () => { + const { editor, setNodeMarkup } = makeEditor({ + rightToLeft: true, + }); + + paragraphsSetAlignmentWrapper(editor, { + target: { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' }, + alignment: 'left', + }); + + const nextAttrs = setNodeMarkup.mock.calls[0]?.[2] as { paragraphProperties: Record }; + expect(nextAttrs.paragraphProperties.justification).toBe('right'); + }); + + it('uses resolved RTL from style cascade when raw paragraph attrs are LTR/empty', () => { + vi.mocked(calculateResolvedParagraphProperties).mockReturnValueOnce({ rightToLeft: true }); + const { editor, setNodeMarkup } = makeEditor({}); + + paragraphsSetAlignmentWrapper(editor, { + target: { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' }, + alignment: 'left', + }); + + const nextAttrs = setNodeMarkup.mock.calls[0]?.[2] as { paragraphProperties: Record }; + expect(nextAttrs.paragraphProperties.justification).toBe('right'); + }); +}); diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/paragraphs-wrappers.ts b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/paragraphs-wrappers.ts index 5f26e674e2..a4be4006c9 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/paragraphs-wrappers.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/plan-engine/paragraphs-wrappers.ts @@ -44,6 +44,8 @@ import { findBlockByIdStrict, type BlockCandidate } from '../helpers/node-addres import { DocumentApiAdapterError } from '../errors.js'; import { rejectTrackedMode } from '../helpers/mutation-helpers.js'; import { executeDomainCommand } from './plan-wrappers.js'; +import { mapDisplayAlignmentToStoredJustification } from '../../core/helpers/paragraph-alignment.js'; +import { calculateResolvedParagraphProperties } from '../../extensions/paragraph/resolvedPropertiesCache.js'; // --------------------------------------------------------------------------- // Paragraph block types accepted by this adapter @@ -194,7 +196,7 @@ function mutateParagraphProperties( candidate: BlockCandidate, operation: string, target: ParagraphTarget, - transform: (pPr: PPr) => PPr, + transform: (pPr: PPr, node: any, pos: number) => PPr, options?: MutationOptions, extras?: { clearDirectFormatting?: boolean; @@ -209,7 +211,7 @@ function mutateParagraphProperties( if (!node) return false; const existing = (node.attrs as { paragraphProperties?: PPr }).paragraphProperties ?? {}; - const updated = transform({ ...existing }); + const updated = transform({ ...existing }, node, candidate.pos); if (JSON.stringify(existing) === JSON.stringify(updated)) return false; @@ -238,12 +240,9 @@ function mutateParagraphProperties( // Alignment mapping — external API → OOXML justification value // --------------------------------------------------------------------------- -export const ALIGNMENT_TO_JUSTIFICATION: Record = { - left: 'left', - center: 'center', - right: 'right', - justify: 'both', -}; +export function mapAlignmentToJustificationForParagraph(alignment: ParagraphAlignment, isRtl?: boolean): string { + return mapDisplayAlignmentToStoredJustification(alignment, isRtl); +} // --------------------------------------------------------------------------- // Property helpers @@ -384,10 +383,14 @@ export function paragraphsSetAlignmentWrapper( candidate, 'format.paragraph.setAlignment', input.target, - (pPr) => ({ - ...pPr, - justification: ALIGNMENT_TO_JUSTIFICATION[input.alignment], - }), + (pPr, node, pos) => { + const paragraphPos = typeof editor.state.doc.resolve === 'function' ? editor.state.doc.resolve(pos) : null; + const resolved = calculateResolvedParagraphProperties(editor, node, paragraphPos as any); + return { + ...pPr, + justification: mapAlignmentToJustificationForParagraph(input.alignment, resolved?.rightToLeft === true), + }; + }, options, ); } diff --git a/packages/super-editor/src/editors/v1/extensions/text-align/text-align.js b/packages/super-editor/src/editors/v1/extensions/text-align/text-align.js index de16fe2bbf..7f410692d2 100644 --- a/packages/super-editor/src/editors/v1/extensions/text-align/text-align.js +++ b/packages/super-editor/src/editors/v1/extensions/text-align/text-align.js @@ -1,5 +1,7 @@ // @ts-nocheck import { Extension } from '@core/Extension.js'; +import { mapDisplayAlignmentToStoredJustification } from '../../core/helpers/paragraph-alignment.js'; +import { calculateResolvedParagraphProperties } from '../paragraph/resolvedPropertiesCache.js'; /** * Configuration options for TextAlign @@ -39,11 +41,34 @@ export const TextAlign = Extension.create({ */ setTextAlign: (alignment) => - ({ commands }) => { + ({ commands, state }) => { const containsAlignment = this.options.alignments.includes(alignment); if (!containsAlignment) return false; + const $from = state?.selection?.$from; + let paragraphNode = null; + let paragraphDepth = -1; - return commands.updateAttributes('paragraph', { 'paragraphProperties.justification': alignment }); + if ($from) { + for (let depth = $from.depth; depth >= 0; depth--) { + const nodeAtDepth = $from.node(depth); + if (nodeAtDepth?.type?.name === 'paragraph') { + paragraphNode = nodeAtDepth; + paragraphDepth = depth; + break; + } + } + } + + let paragraphProperties = paragraphNode?.attrs?.paragraphProperties ?? {}; + + if (this.editor && $from && paragraphNode && paragraphDepth > 0) { + const paragraphStartPos = $from.before(paragraphDepth); + const paragraphPos = state.doc.resolve(paragraphStartPos); + paragraphProperties = calculateResolvedParagraphProperties(this.editor, paragraphNode, paragraphPos); + } + + const storedAlignment = mapDisplayAlignmentToStoredJustification(alignment, paragraphProperties?.rightToLeft); + return commands.updateAttributes('paragraph', { 'paragraphProperties.justification': storedAlignment }); }, /** diff --git a/packages/super-editor/src/editors/v1/extensions/text-align/text-align.test.js b/packages/super-editor/src/editors/v1/extensions/text-align/text-align.test.js new file mode 100644 index 0000000000..24b66c9e53 --- /dev/null +++ b/packages/super-editor/src/editors/v1/extensions/text-align/text-align.test.js @@ -0,0 +1,158 @@ +import { describe, it, expect, vi } from 'vitest'; +import { calculateResolvedParagraphProperties } from '../paragraph/resolvedPropertiesCache.js'; +import { TextAlign } from './text-align.js'; + +vi.mock('../paragraph/resolvedPropertiesCache.js', () => ({ + calculateResolvedParagraphProperties: vi.fn((_editor, node) => node?.attrs?.paragraphProperties ?? {}), +})); + +describe('TextAlign extension', () => { + const extensionContext = { + options: TextAlign.config.addOptions(), + editor: { converter: { translatedNumbering: {}, translatedLinkedStyles: {} } }, + }; + const commands = TextAlign.config.addCommands.call({ + ...extensionContext, + }); + + const makeState = (paragraphProperties = {}) => ({ + doc: { + resolve: vi.fn((pos) => ({ pos })), + }, + selection: { + $from: { + depth: 1, + before: vi.fn(() => 0), + node: vi.fn((depth) => + depth === 1 + ? { + type: { name: 'paragraph' }, + attrs: { paragraphProperties }, + } + : { type: { name: 'doc' }, attrs: {} }, + ), + parent: { + type: { name: 'paragraph' }, + attrs: { paragraphProperties }, + }, + }, + }, + }); + + it('writes alignment as-is for LTR paragraphs', () => { + const updateAttributes = vi.fn(() => true); + + const result = commands.setTextAlign('left')({ + commands: { updateAttributes }, + state: makeState({ rightToLeft: false }), + }); + + expect(result).toBe(true); + expect(updateAttributes).toHaveBeenCalledWith('paragraph', { + 'paragraphProperties.justification': 'left', + }); + }); + + it('mirrors left/right for RTL paragraphs', () => { + const updateAttributes = vi.fn(() => true); + + const leftResult = commands.setTextAlign('left')({ + commands: { updateAttributes }, + state: makeState({ rightToLeft: true }), + }); + + const rightResult = commands.setTextAlign('right')({ + commands: { updateAttributes }, + state: makeState({ rightToLeft: true }), + }); + + expect(leftResult).toBe(true); + expect(rightResult).toBe(true); + expect(updateAttributes).toHaveBeenNthCalledWith(1, 'paragraph', { + 'paragraphProperties.justification': 'right', + }); + expect(updateAttributes).toHaveBeenNthCalledWith(2, 'paragraph', { + 'paragraphProperties.justification': 'left', + }); + }); + + it('uses resolved RTL from style cascade when raw paragraph attrs are LTR/empty', () => { + vi.mocked(calculateResolvedParagraphProperties).mockReturnValueOnce({ rightToLeft: true }); + const updateAttributes = vi.fn(() => true); + + const result = commands.setTextAlign('left')({ + commands: { updateAttributes }, + state: makeState({}), + }); + + expect(result).toBe(true); + expect(updateAttributes).toHaveBeenCalledWith('paragraph', { + 'paragraphProperties.justification': 'right', + }); + }); + + it('resolves paragraph ancestor when selection parent is run', () => { + vi.mocked(calculateResolvedParagraphProperties).mockReturnValueOnce({ rightToLeft: true }); + const updateAttributes = vi.fn(() => true); + const state = { + doc: { + resolve: vi.fn((pos) => ({ pos })), + }, + selection: { + $from: { + depth: 2, + before: vi.fn((depth) => (depth === 1 ? 5 : 0)), + node: vi.fn((depth) => { + if (depth === 2) return { type: { name: 'run' }, attrs: {} }; + if (depth === 1) + return { type: { name: 'paragraph' }, attrs: { paragraphProperties: { rightToLeft: false } } }; + return { type: { name: 'doc' }, attrs: {} }; + }), + parent: { type: { name: 'run' }, attrs: {} }, + }, + }, + }; + + commands.setTextAlign('left')({ + commands: { updateAttributes }, + state, + }); + + expect(calculateResolvedParagraphProperties).toHaveBeenCalled(); + expect(updateAttributes).toHaveBeenCalledWith('paragraph', { + 'paragraphProperties.justification': 'right', + }); + }); + + it('keeps center and justify unchanged for RTL paragraphs', () => { + const updateAttributes = vi.fn(() => true); + + commands.setTextAlign('center')({ + commands: { updateAttributes }, + state: makeState({ rightToLeft: true }), + }); + commands.setTextAlign('justify')({ + commands: { updateAttributes }, + state: makeState({ rightToLeft: true }), + }); + + expect(updateAttributes).toHaveBeenNthCalledWith(1, 'paragraph', { + 'paragraphProperties.justification': 'center', + }); + expect(updateAttributes).toHaveBeenNthCalledWith(2, 'paragraph', { + 'paragraphProperties.justification': 'both', + }); + }); + + it('returns false for unsupported alignment values', () => { + const updateAttributes = vi.fn(() => true); + + const result = commands.setTextAlign('start')({ + commands: { updateAttributes }, + state: makeState(), + }); + + expect(result).toBe(false); + expect(updateAttributes).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/super-editor/src/headless-toolbar/helpers/paragraph.ts b/packages/super-editor/src/headless-toolbar/helpers/paragraph.ts index 3875abd8e3..dc188c5564 100644 --- a/packages/super-editor/src/headless-toolbar/helpers/paragraph.ts +++ b/packages/super-editor/src/headless-toolbar/helpers/paragraph.ts @@ -3,18 +3,16 @@ import { numberingInfoToOrderedStyle } from '../../editors/v1/core/helpers/list- import type { OrderedListStyle } from '../../editors/v1/extensions/types/paragraph-commands.js'; import { twipsToLines } from '../../editors/v1/core/super-converter/helpers.js'; import { getQuickFormatList } from '../../editors/v1/extensions/linked-styles/index.js'; +import { mapStoredJustificationToDisplayAlignment } from '../../editors/v1/core/helpers/paragraph-alignment.js'; import { getCurrentParagraphParent, getCurrentResolvedParagraphProperties, resolveStateEditor } from './context.js'; import { createDirectCommandExecute, isCommandDisabled } from './general.js'; import type { ToolbarCommandState, ToolbarContext } from '../types.js'; const getCurrentParagraphJustification = (context: ToolbarContext | null) => { - const justification = getCurrentResolvedParagraphProperties(context)?.justification ?? null; - - if (justification === 'both') { - return 'justify'; - } - - return justification; + const paragraphProperties = getCurrentResolvedParagraphProperties(context); + const justification = paragraphProperties?.justification ?? null; + const isRtl = paragraphProperties?.rightToLeft === true; + return mapStoredJustificationToDisplayAlignment(justification, isRtl); }; export const createTextAlignStateDeriver = diff --git a/packages/super-editor/src/headless-toolbar/toolbar-registry.test.ts b/packages/super-editor/src/headless-toolbar/toolbar-registry.test.ts index 3044ddf41e..9813e30937 100644 --- a/packages/super-editor/src/headless-toolbar/toolbar-registry.test.ts +++ b/packages/super-editor/src/headless-toolbar/toolbar-registry.test.ts @@ -376,6 +376,137 @@ describe('createToolbarRegistry', () => { }); }); + it('derives mirrored text-align for RTL paragraph with explicit right justification', () => { + const registry = createToolbarRegistry(); + const state = registry['text-align']?.state({ + context: { + ...createContext(), + editor: { + state: { + doc: { + resolve: vi.fn(() => '$resolved-pos'), + }, + selection: { + $from: { + depth: 1, + node: vi.fn((depth) => + depth === 1 + ? { + type: { name: 'paragraph' }, + attrs: { + paragraphProperties: { + rightToLeft: true, + justification: 'right', + }, + }, + } + : null, + ), + before: vi.fn(() => 5), + start: vi.fn(() => 6), + }, + }, + }, + converter: null, + } as any, + }, + superdoc: {}, + }); + + expect(state).toEqual({ + active: true, + disabled: false, + value: 'left', + }); + }); + + it('derives mirrored text-align for RTL paragraph with explicit left justification', () => { + const registry = createToolbarRegistry(); + const state = registry['text-align']?.state({ + context: { + ...createContext(), + editor: { + state: { + doc: { + resolve: vi.fn(() => '$resolved-pos'), + }, + selection: { + $from: { + depth: 1, + node: vi.fn((depth) => + depth === 1 + ? { + type: { name: 'paragraph' }, + attrs: { + paragraphProperties: { + rightToLeft: true, + justification: 'left', + }, + }, + } + : null, + ), + before: vi.fn(() => 5), + start: vi.fn(() => 6), + }, + }, + }, + converter: null, + } as any, + }, + superdoc: {}, + }); + + expect(state).toEqual({ + active: true, + disabled: false, + value: 'right', + }); + }); + + it('defaults text-align to right for RTL paragraph when justification is missing', () => { + const registry = createToolbarRegistry(); + const state = registry['text-align']?.state({ + context: { + ...createContext(), + editor: { + state: { + doc: { + resolve: vi.fn(() => '$resolved-pos'), + }, + selection: { + $from: { + depth: 1, + node: vi.fn((depth) => + depth === 1 + ? { + type: { name: 'paragraph' }, + attrs: { + paragraphProperties: { + rightToLeft: true, + }, + }, + } + : null, + ), + before: vi.fn(() => 5), + start: vi.fn(() => 6), + }, + }, + }, + converter: null, + } as any, + }, + superdoc: {}, + }); + + expect(state).toEqual({ + active: true, + disabled: false, + value: 'right', + }); + }); + it('derives line-height value from paragraph spacing', () => { const registry = createToolbarRegistry(); const state = registry['line-height']?.state({ From 60da67d4ec62cc96f0fc4db2936bc008e3097a44 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 12 May 2026 17:17:59 -0300 Subject: [PATCH 2/4] test(rtl-alignment): cover helper roundtrip + toolbar writer + doc-api export Adds three coverage layers for the SD-3094 writer-side mirror: - paragraph-alignment unit (23 tests): direct coverage of the new helper pair (mapDisplayAlignmentToStoredJustification + mapStoredJustificationToDisplayAlignment), including the absent-value default and a display->stored->display roundtrip for every alignment. - Toolbar behavior (4 tests): clicking each align button on an RTL paragraph writes the expected mirrored OOXML justification (verified via the doc-api). - Doc-API story (5 tests): format.paragraph.setAlignment on an RTL paragraph exports the expected w:jc value (left->right, right->left, center->center, justify->both); LTR baseline unchanged. Fixture rtl-paragraph-alignment.docx is duplicated from the SD-3093 behavior tests so this PR's CI can run standalone; it will collapse to the SD-3093 copy when the two PRs merge. --- .../core/helpers/paragraph-alignment.test.js | 105 +++++++++ .../fixtures/rtl-paragraph-alignment.docx | Bin 0 -> 12958 bytes .../tests/toolbar/alignment-rtl.spec.ts | 81 +++++++ .../tests/formatting/rtl-alignment-api.ts | 202 ++++++++++++++++++ 4 files changed, 388 insertions(+) create mode 100644 packages/super-editor/src/editors/v1/core/helpers/paragraph-alignment.test.js create mode 100644 tests/behavior/tests/formatting/fixtures/rtl-paragraph-alignment.docx create mode 100644 tests/behavior/tests/toolbar/alignment-rtl.spec.ts create mode 100644 tests/doc-api-stories/tests/formatting/rtl-alignment-api.ts diff --git a/packages/super-editor/src/editors/v1/core/helpers/paragraph-alignment.test.js b/packages/super-editor/src/editors/v1/core/helpers/paragraph-alignment.test.js new file mode 100644 index 0000000000..77c1fee0bb --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/helpers/paragraph-alignment.test.js @@ -0,0 +1,105 @@ +import { describe, it, expect } from 'vitest'; +import { + mapDisplayAlignmentToStoredJustification, + mapStoredJustificationToDisplayAlignment, +} from './paragraph-alignment.js'; + +// SD-3094: per ECMA-376 §17.3.1.13, `w:jc="left"` is the leading edge of the +// paragraph, not the physical left. In RTL paragraphs (`w:bidi`), leading is +// the right side. The two helpers below own the display ↔ stored translation +// so the editor can keep its UI in physical terms while the model stays in the +// spec's logical terms. + +describe('mapDisplayAlignmentToStoredJustification', () => { + describe('LTR paragraphs (isRtl=false)', () => { + it('passes left/right/center through unchanged', () => { + expect(mapDisplayAlignmentToStoredJustification('left', false)).toBe('left'); + expect(mapDisplayAlignmentToStoredJustification('right', false)).toBe('right'); + expect(mapDisplayAlignmentToStoredJustification('center', false)).toBe('center'); + }); + + it('maps justify to OOXML both', () => { + expect(mapDisplayAlignmentToStoredJustification('justify', false)).toBe('both'); + }); + }); + + describe('RTL paragraphs (isRtl=true)', () => { + it('mirrors left to right', () => { + expect(mapDisplayAlignmentToStoredJustification('left', true)).toBe('right'); + }); + + it('mirrors right to left', () => { + expect(mapDisplayAlignmentToStoredJustification('right', true)).toBe('left'); + }); + + it('keeps center unchanged', () => { + expect(mapDisplayAlignmentToStoredJustification('center', true)).toBe('center'); + }); + + it('maps justify to OOXML both (no direction-flip)', () => { + expect(mapDisplayAlignmentToStoredJustification('justify', true)).toBe('both'); + }); + }); + + it('justify always maps to both regardless of direction', () => { + expect(mapDisplayAlignmentToStoredJustification('justify', false)).toBe('both'); + expect(mapDisplayAlignmentToStoredJustification('justify', true)).toBe('both'); + }); +}); + +describe('mapStoredJustificationToDisplayAlignment', () => { + describe('LTR paragraphs (isRtl=false)', () => { + it('passes left/right/center through unchanged', () => { + expect(mapStoredJustificationToDisplayAlignment('left', false)).toBe('left'); + expect(mapStoredJustificationToDisplayAlignment('right', false)).toBe('right'); + expect(mapStoredJustificationToDisplayAlignment('center', false)).toBe('center'); + }); + + it('maps both to justify', () => { + expect(mapStoredJustificationToDisplayAlignment('both', false)).toBe('justify'); + }); + + it('defaults to left when justification is absent', () => { + expect(mapStoredJustificationToDisplayAlignment(null, false)).toBe('left'); + expect(mapStoredJustificationToDisplayAlignment(undefined, false)).toBe('left'); + expect(mapStoredJustificationToDisplayAlignment('', false)).toBe('left'); + }); + }); + + describe('RTL paragraphs (isRtl=true)', () => { + it('mirrors stored left to display right', () => { + expect(mapStoredJustificationToDisplayAlignment('left', true)).toBe('right'); + }); + + it('mirrors stored right to display left', () => { + expect(mapStoredJustificationToDisplayAlignment('right', true)).toBe('left'); + }); + + it('keeps center unchanged', () => { + expect(mapStoredJustificationToDisplayAlignment('center', true)).toBe('center'); + }); + + it('maps both to justify (no direction-flip)', () => { + expect(mapStoredJustificationToDisplayAlignment('both', true)).toBe('justify'); + }); + + it('defaults to right when justification is absent', () => { + expect(mapStoredJustificationToDisplayAlignment(null, true)).toBe('right'); + expect(mapStoredJustificationToDisplayAlignment(undefined, true)).toBe('right'); + expect(mapStoredJustificationToDisplayAlignment('', true)).toBe('right'); + }); + }); + + // Roundtrip: writing then reading must return the original display value. + describe('roundtrip display → stored → display', () => { + for (const isRtl of [false, true]) { + for (const display of /** @type {const} */ (['left', 'center', 'right', 'justify'])) { + it(`${display} on ${isRtl ? 'RTL' : 'LTR'} survives a write+read cycle`, () => { + const stored = mapDisplayAlignmentToStoredJustification(display, isRtl); + const readBack = mapStoredJustificationToDisplayAlignment(stored, isRtl); + expect(readBack).toBe(display); + }); + } + } + }); +}); diff --git a/tests/behavior/tests/formatting/fixtures/rtl-paragraph-alignment.docx b/tests/behavior/tests/formatting/fixtures/rtl-paragraph-alignment.docx new file mode 100644 index 0000000000000000000000000000000000000000..b3f138066f96bcd70dd33bd3bcff863bdf75fec7 GIT binary patch literal 12958 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 literal 0 HcmV?d00001 diff --git a/tests/behavior/tests/toolbar/alignment-rtl.spec.ts b/tests/behavior/tests/toolbar/alignment-rtl.spec.ts new file mode 100644 index 0000000000..3aa37165e2 --- /dev/null +++ b/tests/behavior/tests/toolbar/alignment-rtl.spec.ts @@ -0,0 +1,81 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test, type SuperDocFixture } from '../../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const RTL_DOC = path.resolve(__dirname, '../formatting/fixtures/rtl-paragraph-alignment.docx'); + +test.use({ config: { toolbar: 'full', showSelection: true } }); + +async function clickAlignment(superdoc: SuperDocFixture, ariaLabel: string): Promise { + await superdoc.page.locator('[data-item="btn-textAlign"]').click(); + await superdoc.waitForStable(); + await superdoc.page.locator(`[data-item="btn-textAlign-option"][aria-label="${ariaLabel}"]`).click(); + await superdoc.waitForStable(); +} + +// SD-3094: toolbar writer must mirror display alignment to the stored OOXML +// value on RTL paragraphs (per ECMA-376 §17.3.1.13: left = leading edge = +// stored 'right' in RTL). Visual rendering is verified separately by the +// rtl-paragraph-alignment-import.spec.ts that ships with SD-3093. + +test('clicking Align Left on RTL paragraph stores OOXML w:jc="right"', async ({ superdoc }) => { + await superdoc.loadDocument(RTL_DOC); + await superdoc.waitForStable(); + + const line = superdoc.page + .locator('.superdoc-page .superdoc-fragment .superdoc-line') + .filter({ hasText: 'jc=center' }) + .first(); + await line.click(); + await superdoc.waitForStable(); + + await clickAlignment(superdoc, 'Align left'); + await superdoc.assertTextAlignment('jc=center', 'right'); +}); + +test('clicking Align Right on RTL paragraph stores OOXML w:jc="left"', async ({ superdoc }) => { + await superdoc.loadDocument(RTL_DOC); + await superdoc.waitForStable(); + + const line = superdoc.page + .locator('.superdoc-page .superdoc-fragment .superdoc-line') + .filter({ hasText: 'jc=center' }) + .first(); + await line.click(); + await superdoc.waitForStable(); + + await clickAlignment(superdoc, 'Align right'); + await superdoc.assertTextAlignment('jc=center', 'left'); +}); + +test('clicking Justify on RTL paragraph stores OOXML w:jc="both" (normalized)', async ({ superdoc }) => { + await superdoc.loadDocument(RTL_DOC); + await superdoc.waitForStable(); + + const line = superdoc.page + .locator('.superdoc-page .superdoc-fragment .superdoc-line') + .filter({ hasText: 'jc=center' }) + .first(); + await line.click(); + await superdoc.waitForStable(); + + await clickAlignment(superdoc, 'Justify'); + // The doc-api normalizes OOXML 'both' to 'justify' on read. + await superdoc.assertTextAlignment('jc=center', 'justify'); +}); + +test('clicking Align Center on RTL paragraph stores OOXML w:jc="center" (no mirror)', async ({ superdoc }) => { + await superdoc.loadDocument(RTL_DOC); + await superdoc.waitForStable(); + + const line = superdoc.page + .locator('.superdoc-page .superdoc-fragment .superdoc-line') + .filter({ hasText: 'jc=left' }) + .first(); + await line.click(); + await superdoc.waitForStable(); + + await clickAlignment(superdoc, 'Align center'); + await superdoc.assertTextAlignment('jc=left', 'center'); +}); diff --git a/tests/doc-api-stories/tests/formatting/rtl-alignment-api.ts b/tests/doc-api-stories/tests/formatting/rtl-alignment-api.ts new file mode 100644 index 0000000000..7d23a65ab9 --- /dev/null +++ b/tests/doc-api-stories/tests/formatting/rtl-alignment-api.ts @@ -0,0 +1,202 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { describe, expect, it } from 'vitest'; +import { unwrap, useStoryHarness } from '../harness'; + +const execFileAsync = promisify(execFile); +const ZIP_MAX_BUFFER_BYTES = 10 * 1024 * 1024; + +async function readDocxPart(docPath: string, partPath: string): Promise { + const { stdout } = await execFileAsync('unzip', ['-p', docPath, partPath], { + maxBuffer: ZIP_MAX_BUFFER_BYTES, + }); + return stdout; +} + +function extractParagraphXmls(documentXml: string): string[] { + return [...documentXml.matchAll(//g)].map((match) => match[0]); +} + +function countMatches(source: string, pattern: RegExp): number { + return [...source.matchAll(pattern)].length; +} + +function makeSessionId(label: string): string { + return `${label}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +// SD-3094 / SD-3093: When a paragraph is RTL (w:bidi), the doc-api +// `format.paragraph.setAlignment` takes a *display* alignment (what the user +// sees) and must write the spec-correct *stored* w:jc value per ECMA-376 +// §17.3.1.13 (left = leading edge, right = trailing edge). +describe('document-api story: rtl paragraph alignment write', () => { + const { client, outPath } = useStoryHarness('formatting/rtl-alignment-api', { + preserveResults: true, + }); + + const api = client as any; + + async function openBlankWithText(sessionId: string, text: string): Promise { + await api.doc.open({ sessionId }); + const insertResult = unwrap(await api.doc.insert({ sessionId, value: text })); + expect(insertResult?.receipt?.success).toBe(true); + } + + async function paragraphTargetForText(sessionId: string, text: string) { + const result = unwrap( + await api.doc.query.match({ + sessionId, + select: { type: 'text', pattern: text, caseSensitive: true }, + require: 'first', + }), + ); + const item = result?.items?.[0]; + expect(item?.address?.kind).toBe('block'); + expect(item?.address?.nodeType).toBe('paragraph'); + return item.address; + } + + async function makeRtlParagraph(sessionId: string, text: string) { + await openBlankWithText(sessionId, text); + const target = await paragraphTargetForText(sessionId, text); + const result = unwrap( + await api.doc.format.paragraph.setDirection({ + sessionId, + target, + direction: 'rtl', + alignmentPolicy: 'preserve', + }), + ); + expect(result?.success).toBe(true); + return target; + } + + async function saveResult(sessionId: string, name: string): Promise { + const savePath = outPath(name); + await api.doc.save({ sessionId, out: savePath, force: true }); + return savePath; + } + + it('setAlignment(left) on RTL paragraph exports w:jc=right (mirrored to trailing-edge stored value)', async () => { + const sessionId = makeSessionId('rtl-align-left'); + const paragraphText = 'RTL paragraph align-left case'; + + const target = await makeRtlParagraph(sessionId, paragraphText); + + const result = unwrap( + await api.doc.format.paragraph.setAlignment({ + sessionId, + target, + alignment: 'left', + }), + ); + expect(result?.success).toBe(true); + + const docPath = await saveResult(sessionId, 'rtl-align-left.docx'); + const documentXml = await readDocxPart(docPath, 'word/document.xml'); + const paragraphs = extractParagraphXmls(documentXml); + + expect(paragraphs).toHaveLength(1); + expect(countMatches(paragraphs[0], /]*\/>/g)).toBe(1); + expect(countMatches(paragraphs[0], /]*w:val="right"[^>]*\/>/g)).toBe(1); + expect(countMatches(paragraphs[0], /]*w:val="left"[^>]*\/>/g)).toBe(0); + }); + + it('setAlignment(right) on RTL paragraph exports w:jc=left (mirrored to leading-edge stored value)', async () => { + const sessionId = makeSessionId('rtl-align-right'); + const paragraphText = 'RTL paragraph align-right case'; + + const target = await makeRtlParagraph(sessionId, paragraphText); + + const result = unwrap( + await api.doc.format.paragraph.setAlignment({ + sessionId, + target, + alignment: 'right', + }), + ); + expect(result?.success).toBe(true); + + const docPath = await saveResult(sessionId, 'rtl-align-right.docx'); + const documentXml = await readDocxPart(docPath, 'word/document.xml'); + const paragraphs = extractParagraphXmls(documentXml); + + expect(paragraphs).toHaveLength(1); + expect(countMatches(paragraphs[0], /]*\/>/g)).toBe(1); + expect(countMatches(paragraphs[0], /]*w:val="left"[^>]*\/>/g)).toBe(1); + expect(countMatches(paragraphs[0], /]*w:val="right"[^>]*\/>/g)).toBe(0); + }); + + it('setAlignment(center) on RTL paragraph exports w:jc=center (no mirror)', async () => { + const sessionId = makeSessionId('rtl-align-center'); + const paragraphText = 'RTL paragraph align-center case'; + + const target = await makeRtlParagraph(sessionId, paragraphText); + + const result = unwrap( + await api.doc.format.paragraph.setAlignment({ + sessionId, + target, + alignment: 'center', + }), + ); + expect(result?.success).toBe(true); + + const docPath = await saveResult(sessionId, 'rtl-align-center.docx'); + const documentXml = await readDocxPart(docPath, 'word/document.xml'); + const paragraphs = extractParagraphXmls(documentXml); + + expect(paragraphs).toHaveLength(1); + expect(countMatches(paragraphs[0], /]*\/>/g)).toBe(1); + expect(countMatches(paragraphs[0], /]*w:val="center"[^>]*\/>/g)).toBe(1); + }); + + it('setAlignment(justify) on RTL paragraph exports w:jc=both (justify normalizes to both, no mirror)', async () => { + const sessionId = makeSessionId('rtl-align-justify'); + const paragraphText = 'RTL paragraph align-justify case'; + + const target = await makeRtlParagraph(sessionId, paragraphText); + + const result = unwrap( + await api.doc.format.paragraph.setAlignment({ + sessionId, + target, + alignment: 'justify', + }), + ); + expect(result?.success).toBe(true); + + const docPath = await saveResult(sessionId, 'rtl-align-justify.docx'); + const documentXml = await readDocxPart(docPath, 'word/document.xml'); + const paragraphs = extractParagraphXmls(documentXml); + + expect(paragraphs).toHaveLength(1); + expect(countMatches(paragraphs[0], /]*\/>/g)).toBe(1); + expect(countMatches(paragraphs[0], /]*w:val="both"[^>]*\/>/g)).toBe(1); + }); + + it('setAlignment on LTR paragraph still writes display value unchanged', async () => { + const sessionId = makeSessionId('ltr-align-baseline'); + const paragraphText = 'LTR paragraph baseline case'; + + await openBlankWithText(sessionId, paragraphText); + const target = await paragraphTargetForText(sessionId, paragraphText); + + const result = unwrap( + await api.doc.format.paragraph.setAlignment({ + sessionId, + target, + alignment: 'left', + }), + ); + expect(result?.success).toBe(true); + + const docPath = await saveResult(sessionId, 'ltr-align-left.docx'); + const documentXml = await readDocxPart(docPath, 'word/document.xml'); + const paragraphs = extractParagraphXmls(documentXml); + + expect(paragraphs).toHaveLength(1); + expect(countMatches(paragraphs[0], /]*\/>/g)).toBe(0); + expect(countMatches(paragraphs[0], /]*w:val="left"[^>]*\/>/g)).toBe(1); + }); +}); From c93db707bdd0b458c3adc6217a78ffea2e8877be Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 12 May 2026 19:25:34 -0300 Subject: [PATCH 3/4] =?UTF-8?q?test(rtl-alignment):=20cover=20section-bidi?= =?UTF-8?q?-only=20edge=20case=20(ECMA-376=20=C2=A717.6.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds regression coverage for the spec rule that section-level w:bidi must NOT cascade to paragraph w:jc interpretation. Section bidi affects only section-level properties (page numbers, columns), not paragraph alignment. The fixture has a section with but two paragraphs without paragraph-level . Tests verify that clicking Align Left or Align Right on these paragraphs stores the display value unchanged (no mirror), since the paragraph itself is LTR per spec. --- .../fixtures/rtl-section-only-bidi.docx | Bin 0 -> 12919 bytes .../alignment-section-bidi-only.spec.ts | 57 ++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 tests/behavior/tests/formatting/fixtures/rtl-section-only-bidi.docx create mode 100644 tests/behavior/tests/toolbar/alignment-section-bidi-only.spec.ts diff --git a/tests/behavior/tests/formatting/fixtures/rtl-section-only-bidi.docx b/tests/behavior/tests/formatting/fixtures/rtl-section-only-bidi.docx new file mode 100644 index 0000000000000000000000000000000000000000..eb4968edc98cee9a297b55674b982c0e287babb4 GIT binary patch literal 12919 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 B12zBv literal 0 HcmV?d00001 diff --git a/tests/behavior/tests/toolbar/alignment-section-bidi-only.spec.ts b/tests/behavior/tests/toolbar/alignment-section-bidi-only.spec.ts new file mode 100644 index 0000000000..77fbd824a8 --- /dev/null +++ b/tests/behavior/tests/toolbar/alignment-section-bidi-only.spec.ts @@ -0,0 +1,57 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test, type SuperDocFixture } from '../../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const SECTION_BIDI_DOC = path.resolve(__dirname, '../formatting/fixtures/rtl-section-only-bidi.docx'); + +test.use({ config: { toolbar: 'full', showSelection: true } }); + +async function clickAlignment(superdoc: SuperDocFixture, ariaLabel: string): Promise { + await superdoc.page.locator('[data-item="btn-textAlign"]').click(); + await superdoc.waitForStable(); + await superdoc.page.locator(`[data-item="btn-textAlign-option"][aria-label="${ariaLabel}"]`).click(); + await superdoc.waitForStable(); +} + +// SD-3094 + ECMA-376 §17.6.1 (section bidi): when the section has w:bidi +// but the paragraph itself does NOT, the section bidi must NOT affect +// paragraph w:jc interpretation. The spec is explicit: "This property +// only affects section-level properties, and does not affect the layout +// of text within the contents of this section." +// +// Practical implication: clicking "Align Left" on such a paragraph +// should write w:jc="left" unchanged (the paragraph isn't RTL even +// though its section is). +test('Align Left on a non-bidi paragraph inside an RTL section stores w:jc="left" unchanged', async ({ superdoc }) => { + await superdoc.loadDocument(SECTION_BIDI_DOC); + await superdoc.waitForStable(); + + const line = superdoc.page + .locator('.superdoc-page .superdoc-fragment .superdoc-line') + .filter({ hasText: 'jc=right' }) + .first(); + await line.click(); + await superdoc.waitForStable(); + + await clickAlignment(superdoc, 'Align left'); + // No mirror: stored value is 'left' (the display value), not 'right'. + await superdoc.assertTextAlignment('jc=right', 'left'); +}); + +test('Align Right on a non-bidi paragraph inside an RTL section stores w:jc="right" unchanged', async ({ + superdoc, +}) => { + await superdoc.loadDocument(SECTION_BIDI_DOC); + await superdoc.waitForStable(); + + const line = superdoc.page + .locator('.superdoc-page .superdoc-fragment .superdoc-line') + .filter({ hasText: 'jc=left' }) + .first(); + await line.click(); + await superdoc.waitForStable(); + + await clickAlignment(superdoc, 'Align right'); + await superdoc.assertTextAlignment('jc=left', 'right'); +}); From 2880cbac0bda2fc62184d1f0781f77fa88095fe5 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 12 May 2026 20:10:13 -0300 Subject: [PATCH 4/4] test(rtl-alignment): cover headings, tables, multi-paragraph selection, and indent Expands SD-3094 behavior coverage across additional surfaces that share the alignment-writer code path: - Headings (Heading1/Heading2 with w:bidi): Align Left/Right mirrors the stored OOXML the same way it does for body paragraphs. - Table cells with RTL direction (w:bidiVisual): Align Left on a Hebrew cell paragraph stores w:jc=right. - Multi-paragraph selection across three RTL paragraphs: Align Left applies the writer mirror per-paragraph, all three end up stored as w:jc=right. - Indent + RTL: Increase Indent on an RTL paragraph updates the paragraph properties (smoke test against the toolbar indent button). Lists + RTL alignment was investigated but deferred: it needs a hand-crafted OOXML fixture with explicit numbering + paragraph-level bidi, which is a bigger scope and better tracked separately. --- .../fixtures/rtl-heading-alignment.docx | Bin 0 -> 12932 bytes .../fixtures/rtl-table-cell-alignment.docx | Bin 0 -> 13815 bytes .../toolbar/alignment-rtl-headings.spec.ts | 49 ++++++++++++++++ .../alignment-rtl-multi-paragraph.spec.ts | 42 ++++++++++++++ .../toolbar/alignment-rtl-table-cells.spec.ts | 36 ++++++++++++ .../behavior/tests/toolbar/indent-rtl.spec.ts | 54 ++++++++++++++++++ 6 files changed, 181 insertions(+) create mode 100644 tests/behavior/tests/formatting/fixtures/rtl-heading-alignment.docx create mode 100644 tests/behavior/tests/formatting/fixtures/rtl-table-cell-alignment.docx create mode 100644 tests/behavior/tests/toolbar/alignment-rtl-headings.spec.ts create mode 100644 tests/behavior/tests/toolbar/alignment-rtl-multi-paragraph.spec.ts create mode 100644 tests/behavior/tests/toolbar/alignment-rtl-table-cells.spec.ts create mode 100644 tests/behavior/tests/toolbar/indent-rtl.spec.ts diff --git a/tests/behavior/tests/formatting/fixtures/rtl-heading-alignment.docx b/tests/behavior/tests/formatting/fixtures/rtl-heading-alignment.docx new file mode 100644 index 0000000000000000000000000000000000000000..9a0ca9dc20f21f0ecae67351337a4d9a2f028110 GIT binary patch literal 12932 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 { + await superdoc.page.locator('[data-item="btn-textAlign"]').click(); + await superdoc.waitForStable(); + await superdoc.page.locator(`[data-item="btn-textAlign-option"][aria-label="${ariaLabel}"]`).click(); + await superdoc.waitForStable(); +} + +// SD-3094: setTextAlign + the doc-api setAlignment paths both apply to +// heading nodes (not just body paragraphs). Verify the RTL mirror logic +// works the same for headings as for paragraphs when w:bidi is set. + +test('Align Left on RTL heading stores OOXML w:jc="right" (writer mirror)', async ({ superdoc }) => { + await superdoc.loadDocument(DOC); + await superdoc.waitForStable(); + + const heading = superdoc.page + .locator('.superdoc-page .superdoc-fragment .superdoc-line') + .filter({ hasText: 'Heading 1' }) + .first(); + await heading.click(); + await superdoc.waitForStable(); + + await clickAlignment(superdoc, 'Align left'); + await superdoc.assertTextAlignment('Heading 1', 'right'); +}); + +test('Align Right on RTL heading stores OOXML w:jc="left" (writer mirror)', async ({ superdoc }) => { + await superdoc.loadDocument(DOC); + await superdoc.waitForStable(); + + const heading = superdoc.page + .locator('.superdoc-page .superdoc-fragment .superdoc-line') + .filter({ hasText: 'Heading 2' }) + .first(); + await heading.click(); + await superdoc.waitForStable(); + + await clickAlignment(superdoc, 'Align right'); + await superdoc.assertTextAlignment('Heading 2', 'left'); +}); diff --git a/tests/behavior/tests/toolbar/alignment-rtl-multi-paragraph.spec.ts b/tests/behavior/tests/toolbar/alignment-rtl-multi-paragraph.spec.ts new file mode 100644 index 0000000000..129488ab84 --- /dev/null +++ b/tests/behavior/tests/toolbar/alignment-rtl-multi-paragraph.spec.ts @@ -0,0 +1,42 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test, type SuperDocFixture } from '../../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DOC = path.resolve(__dirname, '../formatting/fixtures/rtl-paragraph-alignment.docx'); + +test.use({ config: { toolbar: 'full', showSelection: true } }); + +async function clickAlignment(superdoc: SuperDocFixture, ariaLabel: string): Promise { + await superdoc.page.locator('[data-item="btn-textAlign"]').click(); + await superdoc.waitForStable(); + await superdoc.page.locator(`[data-item="btn-textAlign-option"][aria-label="${ariaLabel}"]`).click(); + await superdoc.waitForStable(); +} + +// SD-3094: changing alignment across a selection that spans multiple RTL +// paragraphs must apply the mirror per-paragraph (each paragraph is its +// own bidi context). Selecting all three Hebrew paragraphs in the +// fixture and clicking Align Left should write w:jc="right" on all of +// them. + +test('Align Left across a 3-paragraph RTL selection mirrors each paragraph', async ({ superdoc }) => { + await superdoc.loadDocument(DOC); + await superdoc.waitForStable(); + + // Use the doc-api to select all text (covers all three paragraphs). + await superdoc.page.evaluate(() => { + const editor = (window as any).editor; + const docSize = editor.state.doc.content.size; + editor.commands.setTextSelection({ from: 1, to: docSize - 1 }); + }); + await superdoc.waitForStable(); + + await clickAlignment(superdoc, 'Align left'); + await superdoc.waitForStable(); + + // All three RTL paragraphs should now store w:jc="right". + await superdoc.assertTextAlignment('jc=left', 'right'); + await superdoc.assertTextAlignment('jc=right', 'right'); + await superdoc.assertTextAlignment('jc=center', 'right'); +}); diff --git a/tests/behavior/tests/toolbar/alignment-rtl-table-cells.spec.ts b/tests/behavior/tests/toolbar/alignment-rtl-table-cells.spec.ts new file mode 100644 index 0000000000..71bbe83b15 --- /dev/null +++ b/tests/behavior/tests/toolbar/alignment-rtl-table-cells.spec.ts @@ -0,0 +1,36 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test, type SuperDocFixture } from '../../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DOC = path.resolve(__dirname, '../formatting/fixtures/rtl-table-cell-alignment.docx'); + +test.use({ config: { toolbar: 'full', showSelection: true } }); + +async function clickAlignment(superdoc: SuperDocFixture, ariaLabel: string): Promise { + await superdoc.page.locator('[data-item="btn-textAlign"]').click(); + await superdoc.waitForStable(); + await superdoc.page.locator(`[data-item="btn-textAlign-option"][aria-label="${ariaLabel}"]`).click(); + await superdoc.waitForStable(); +} + +// SD-3094: paragraphs inside RTL-direction table cells should be treated +// the same as standalone RTL paragraphs by the alignment writer. The +// fixture is a bidiVisual table with Hebrew cell content; clicking +// Align Left on a cell paragraph should store w:jc="right" if the cell +// content is recognized as RTL. + +test('Align Left on a Hebrew cell stores w:jc="right" (writer mirror under cell-level RTL)', async ({ superdoc }) => { + await superdoc.loadDocument(DOC); + await superdoc.waitForStable(); + + const cellLine = superdoc.page + .locator('.superdoc-page .superdoc-fragment .superdoc-line') + .filter({ hasText: 'תא 1' }) + .first(); + await cellLine.click(); + await superdoc.waitForStable(); + + await clickAlignment(superdoc, 'Align left'); + await superdoc.assertTextAlignment('תא 1', 'right'); +}); diff --git a/tests/behavior/tests/toolbar/indent-rtl.spec.ts b/tests/behavior/tests/toolbar/indent-rtl.spec.ts new file mode 100644 index 0000000000..5e3b2a390e --- /dev/null +++ b/tests/behavior/tests/toolbar/indent-rtl.spec.ts @@ -0,0 +1,54 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test, expect } from '../../fixtures/superdoc.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DOC = path.resolve(__dirname, '../formatting/fixtures/rtl-paragraph-alignment.docx'); + +test.use({ config: { toolbar: 'full', showSelection: true } }); + +// SD-3094 + ECMA-376 §17.3.1.6: w:bidi affects w:ind the same way it +// affects w:jc. Per the spec, increasing indent on an RTL paragraph +// should grow the leading edge (the right visual side). This spec is +// a smoke test that the Increase Indent toolbar action runs without +// error on an RTL paragraph and produces a paragraph-properties update +// observable through the doc-api. + +test('Increase Indent on RTL paragraph updates paragraph properties', async ({ superdoc }) => { + await superdoc.loadDocument(DOC); + await superdoc.waitForStable(); + + const line = superdoc.page + .locator('.superdoc-page .superdoc-fragment .superdoc-line') + .filter({ hasText: 'jc=center' }) + .first(); + await line.click(); + await superdoc.waitForStable(); + + const before = await superdoc.page.evaluate(() => { + const docApi = (window as any).editor?.doc; + const m = docApi.query.match({ select: { type: 'text', pattern: 'jc=center' }, require: 'first' }); + const addr = m?.items?.[0]?.address; + if (!addr) return null; + const n = docApi.getNode(addr); + const p = (n?.node ?? n).paragraph; + return { indent: p?.props?.indent ?? null }; + }); + + await superdoc.page.locator('[data-item="btn-indentright"]').click(); + await superdoc.waitForStable(); + + const after = await superdoc.page.evaluate(() => { + const docApi = (window as any).editor?.doc; + const m = docApi.query.match({ select: { type: 'text', pattern: 'jc=center' }, require: 'first' }); + const addr = m?.items?.[0]?.address; + if (!addr) return null; + const n = docApi.getNode(addr); + const p = (n?.node ?? n).paragraph; + return { indent: p?.props?.indent ?? null }; + }); + + // The indent must change (an exact value depends on the existing + // indent + step size; we only assert it is no longer the original). + expect(JSON.stringify(after?.indent)).not.toBe(JSON.stringify(before?.indent)); +});