Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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';
Comment thread
artem-harbour marked this conversation as resolved.
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);
}
Original file line number Diff line number Diff line change
@@ -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);
});
}
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down Expand Up @@ -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();
Expand All @@ -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 ?? {};
});
});

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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',
}),
}),
);
});
});

// ---------------------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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;
Expand All @@ -1086,6 +1090,9 @@ function applyAlignmentToRange(tr: Transaction, absFrom: number, absTo: number,
if (!node.isTextblock) return;

const existing = (node.attrs as Record<string, unknown>).paragraphProperties as Record<string, unknown> | 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;
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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 };
Expand Down
Loading
Loading