Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
Expand Up @@ -56,3 +56,101 @@ describe('ButtonGroup dropdownOptions selected class', () => {
expect(options[1].props.class).toBe('selected');
});
});

// PR #3226: ButtonGroup forwards a button item's static `argument` (set via
// useToolbarItem({argument})) on click when no caller arg is passed. This is
// how custom buttons carry fixed args like {direction, alignmentPolicy} into
// emit('command'). If this breaks, such buttons become silent no-ops.
describe('ButtonGroup button argument forwarding', () => {
// `type` and `command` are plain (not refs) in useToolbarItem; the rest are refs.
const createButtonItem = (argument) => ({
type: 'button',
command: 'setParagraphDirection',
id: ref('btn-test'),
name: ref('directionLtr'),
argument: argument === undefined ? undefined : ref(argument),
disabled: ref(false),
isNarrow: ref(false),
isWide: ref(false),
tooltip: ref('Test'),
icon: ref(null),
active: ref(false),
expand: ref(false),
attributes: ref({ ariaLabel: 'Test button' }),
});

// shallowMount stubs all children including SdTooltip; SdTooltip is what
// wraps the button branch via <template #trigger>. Provide a custom stub
// that renders its trigger slot so the ToolbarButton stub becomes findable.
const mountButtonItem = (item) =>
shallowMount(ButtonGroup, {
props: { toolbarItems: [item], overflowItems: [] },
global: {
stubs: {
SdTooltip: {
name: 'SdTooltip',
template: '<div><slot name="trigger" /></div>',
},
},
},
});

const findToolbarButton = (wrapper) => wrapper.findComponent({ name: 'ToolbarButton' });

it('plain button click forwards item.argument.value into command emission', () => {
const argument = { direction: 'ltr', alignmentPolicy: 'matchDirection' };
const wrapper = mountButtonItem(createButtonItem(argument));
const button = findToolbarButton(wrapper);

button.vm.$emit('buttonClick');

const events = wrapper.emitted('command');
expect(events).toHaveLength(1);
expect(events[0][0].argument).toEqual(argument);
});

it('emits null argument when item has no static argument', () => {
const wrapper = mountButtonItem(createButtonItem(undefined));
const button = findToolbarButton(wrapper);

button.vm.$emit('buttonClick');

const events = wrapper.emitted('command');
expect(events).toHaveLength(1);
expect(events[0][0].argument).toBeNull();
});

it('directionLtr-shaped item forwards {direction:ltr, alignmentPolicy:matchDirection}', () => {
const argument = { direction: 'ltr', alignmentPolicy: 'matchDirection' };
const wrapper = mountButtonItem(createButtonItem(argument));
const button = findToolbarButton(wrapper);

button.vm.$emit('buttonClick');

const events = wrapper.emitted('command');
expect(events[0][0].argument.direction).toBe('ltr');
expect(events[0][0].argument.alignmentPolicy).toBe('matchDirection');
});

it('directionRtl-shaped item forwards {direction:rtl, alignmentPolicy:matchDirection}', () => {
const argument = { direction: 'rtl', alignmentPolicy: 'matchDirection' };
const wrapper = mountButtonItem(createButtonItem(argument));
const button = findToolbarButton(wrapper);

button.vm.$emit('buttonClick');

const events = wrapper.emitted('command');
expect(events[0][0].argument.direction).toBe('rtl');
expect(events[0][0].argument.alignmentPolicy).toBe('matchDirection');
});

it('skips command emission when item is disabled', () => {
const disabledItem = { ...createButtonItem({ direction: 'ltr' }), disabled: ref(true) };
const wrapper = mountButtonItem(disabledItem);
const button = findToolbarButton(wrapper);

button.vm.$emit('buttonClick');

expect(wrapper.emitted('command')).toBeUndefined();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,11 @@ const handleToolbarButtonClick = (item, argument = null) => {
}

emit('item-clicked');
emit('command', { item, argument });
// Forward the item's static `argument` (set via `useToolbarItem({ argument })`)
// when no caller-provided argument exists. Lets buttons carry fixed args like
// `{ direction: 'rtl' }` without needing a dropdown.
const resolved = argument ?? item.argument?.value ?? null;
emit('command', { item, argument: resolved });
};

const handleToolbarButtonTextSubmit = (item, argument) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ export const HEADLESS_ITEM_MAP = {
linkedStyles: 'linked-style',
indentleft: 'indent-decrease',
indentright: 'indent-increase',
directionLtr: 'direction-ltr',
directionRtl: 'direction-rtl',
clearFormatting: 'clear-formatting',
copyFormat: 'copy-format',
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,25 @@ describe('makeDefaultItems LG compact styles', () => {
expect(linkedStyles.attributes.value.className).not.toContain('toolbar-item--linked-styles-compact');
});
});

// PR #3226: direction buttons (directionLtr / directionRtl) are intentionally
// NOT in the default toolbar. The command (`setParagraphDirection`) and the
// headless toolbar ids (`direction-ltr` / `direction-rtl`) stay available;
// customers wire them into their own UI via the headless toolbar API or by
// calling the command directly. Pin "not in default" here so a future
// re-add in makeDefaultItems fails this test instead of silently shipping.
describe('makeDefaultItems direction buttons not in default toolbar', () => {
function getItem(defaultItems, overflowItems, name) {
return [...defaultItems, ...overflowItems].find((item) => item.name.value === name);
}

it('directionLtr is not in the default toolbar items', () => {
const { defaultItems, overflowItems } = buildItems(2000);
expect(getItem(defaultItems, overflowItems, 'directionLtr')).toBeUndefined();
});

it('directionRtl is not in the default toolbar items', () => {
const { defaultItems, overflowItems } = buildItems(2000);
expect(getItem(defaultItems, overflowItems, 'directionRtl')).toBeUndefined();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -712,7 +712,11 @@ export class SuperToolbar extends EventEmitter {
return;
}

this.toolbarItems.forEach((item) => {
// Overflow items still appear in the overflow popup and need their
// active-state highlight (e.g., bold pressed, direction matched) to
// reflect the current selection. Iterating only `toolbarItems` left
// them frozen in their last-rendered state.
[...this.toolbarItems, ...this.overflowItems].forEach((item) => {
item.resetDisabled();
this.#applyHeadlessState(item);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ import copyIconSvg from '@superdoc/common/icons/copy-solid.svg?raw';
import pasteIconSvg from '@superdoc/common/icons/paste-solid.svg?raw';
import strikethroughSvg from '@superdoc/common/icons/strikethrough.svg?raw';
import paragraphIconSvg from '@superdoc/common/icons/paragraph-solid.svg?raw';
import paragraphLtrIconSvg from '@superdoc/common/icons/paragraph-ltr-solid.svg?raw';
import paragraphRtlIconSvg from '@superdoc/common/icons/paragraph-rtl-solid.svg?raw';

export const toolbarIcons = {
undo: rotateLeftIconSvg,
Expand Down Expand Up @@ -89,6 +91,8 @@ export const toolbarIcons = {
numberedListLowerAlphaParen: listLowerAlphaParenIconSvg,
indentLeft: outdentIconSvg,
indentRight: indentIconSvg,
directionLtr: paragraphLtrIconSvg,
directionRtl: paragraphRtlIconSvg,
pageBreak: fileHalfDashedIconSvg,
copyFormat: paintRollerIconSvg,
clearFormatting: textSlashIconSvg,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export const toolbarTexts = {
numberedList: 'Numbered list',
indentLeft: 'Left indent',
indentRight: 'Right indent',
directionLtr: 'Left-to-right',
directionRtl: 'Right-to-left',
zoom: 'Zoom',
undo: 'Undo',
redo: 'Redo',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export * from './insertSectionBreakAtSelection.js';
// Paragraph
export * from './textIndent.js';
export * from './lineHeight.js';
export * from './paragraphDirection.js';

// Run
export * from './backspaceEmptyRunParagraph.js';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { resolveHypotheticalParagraphProperties } from '@extensions/paragraph/resolvedPropertiesCache.js';

/**
* Set paragraph direction (LTR/RTL) on every paragraph in the current selection.
* @category Command
* @param {Object} input
* @param {"ltr"|"rtl"} input.direction
* @param {"matchDirection"} [input.alignmentPolicy] - When set to "matchDirection",
* mirror an *explicit* `justification` of "left" ↔ "right" so the alignment
* follows the new direction. Leaves "center", "both", and unset values alone.
* @returns {Function} ProseMirror command function
* @example
* editor.commands.setParagraphDirection({ direction: 'rtl', alignmentPolicy: 'matchDirection' })
*/
export const setParagraphDirection = ({ direction, alignmentPolicy } = {}) => {
// Guard against headless callers that invoke this through a generic
// "execute by command name" pathway without a payload — a missing
// direction must be a no-op, not a silent LTR write.
if (direction !== 'ltr' && direction !== 'rtl') return () => false;
return walkParagraphs((pPr, { editor, $pos }) => {
const next = { ...pPr };
if (direction === 'rtl') {
next.rightToLeft = true;
} else {
// AIDEV-NOTE: LTR first tries to delete the inline override (so a
// vanilla paragraph round-trips without `<w:bidi w:val="0"/>`). But
// if the paragraph inherits `rightToLeft: true` from its style (or
// any other level of the OOXML cascade), deleting alone leaves the
// resolved direction as RTL — clicking LTR would be a silent no-op.
// Re-resolve the cascade against the would-be inline state; if RTL
// still wins, force an explicit `false` to override the style.
delete next.rightToLeft;
const resolved = resolveHypotheticalParagraphProperties(editor, $pos, next);
if (resolved?.rightToLeft === true) {
next.rightToLeft = false;
}
}
if (alignmentPolicy === 'matchDirection') {
const j = pPr.justification;
if (j === 'left' && direction === 'rtl') next.justification = 'right';
else if (j === 'right' && direction === 'ltr') next.justification = 'left';
}
return next;
});
};

/**
* Clear an explicit paragraph direction override on every paragraph in the
* current selection. The paragraph reverts to its auto-resolved direction.
* @category Command
* @returns {Function} ProseMirror command function
* @example
* editor.commands.clearParagraphDirection()
*/
export const clearParagraphDirection = () =>
walkParagraphs((pPr) => {
const next = { ...pPr };
delete next.rightToLeft;
return next;
});

function walkParagraphs(transform) {
return ({ editor, state, dispatch }) => {
const { from, to } = state.selection;
const tr = state.tr;
let touched = false;

state.doc.nodesBetween(from, to, (node, pos) => {
if (node.type.name !== 'paragraph') return true;

const existing = node.attrs.paragraphProperties || {};
const updated = transform(existing, { editor, node, $pos: state.doc.resolve(pos) });

if (shallowEqual(existing, updated)) return false;

tr.setNodeMarkup(pos, undefined, {
...node.attrs,
paragraphProperties: updated,
});
touched = true;
return false;
});

if (touched && dispatch) dispatch(tr);
return touched;
};
}

function shallowEqual(a, b) {
const ka = Object.keys(a);
const kb = Object.keys(b);
if (ka.length !== kb.length) return false;
for (const k of ka) {
if (a[k] !== b[k]) return false;
}
return true;
}
Loading
Loading