Skip to content
Open
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
Expand Up @@ -4,7 +4,7 @@
<div
ref="containerRef"
class="image-node-view"
:class="{ 'is-selected': selected && editor.isEditable }"
:class="{ 'is-selected': selected && editor.isEditable, 'is-end-aligned': isEndAligned }"
:style="{ width: styleWidth }"
>
<img
Expand Down Expand Up @@ -169,11 +169,6 @@
}
};

const isRtl = computed(() => {
// Cache the RTL check result to avoid repeated DOM traversal
return props.editor.view.dom.closest('[dir="rtl"]') !== null;
});

const styleWidth = computed(() => (width.value ? `${width.value}px` : 'auto'));
const isCompact = computed(() => width.value < compactThreshold);

Expand All @@ -196,16 +191,20 @@
return null;
};

const isEndAligned = computed(() => props.node.attrs.textAlign === 'right');

const isHandleOnLeft = () =>
(getComputedStyle(resizeHandleRef.value).direction === 'rtl') !== isEndAligned.value;

const onResizeStart = startEvent => {
const growthPerPixel =
(isHandleOnLeft() ? -1 : 1) * (props.node.attrs.textAlign === 'center' ? 2 : 1);
const startX = startEvent.clientX;
const startWidth = width.value || containerRef.value.offsetWidth;

const onMouseMove = moveEvent => {
const deltaX = moveEvent.clientX - startX;

const newWidth = isRtl.value
? startWidth - deltaX // In RTL, moving right should decrease width
: startWidth + deltaX; // In LTR, moving right should increase width
const newWidth = startWidth + growthPerPixel * deltaX;

const clampedWidth = Math.max(minWidth, newWidth);

Expand Down Expand Up @@ -243,13 +242,13 @@
const currentWidth = width.value || containerRef.value.offsetWidth;
let newWidth = currentWidth;

// Invert keyboard controls for RTL
const rightKey = isRtl.value ? 'ArrowLeft' : 'ArrowRight';
const leftKey = isRtl.value ? 'ArrowRight' : 'ArrowLeft';
const handleOnLeft = isHandleOnLeft();
const growKey = handleOnLeft ? 'ArrowLeft' : 'ArrowRight';
const shrinkKey = handleOnLeft ? 'ArrowRight' : 'ArrowLeft';

if (event.key === rightKey) {
if (event.key === growKey) {
newWidth = currentWidth + step;
} else if (event.key === leftKey) {
} else if (event.key === shrinkKey) {
newWidth = currentWidth - step;
} else if (event.key === 'Escape' || event.key === 'Enter') {
// Use ref instead of DOM query
Expand Down Expand Up @@ -316,6 +315,7 @@
removeImage,
editImage,
isCompact,
isEndAligned,
onResizeKeyDown,
onImageLoad,
};
Expand Down Expand Up @@ -359,16 +359,26 @@
bottom: -6px;
width: 24px;
height: 24px;
cursor: nwse-resize;
cursor: nwse-resize /* rtl:ignore */;
background: white;
border: 2px solid #666666;
border-radius: 50%;
opacity: 0;
transition: opacity 0.2s;
}

[dir='rtl'] .resize-handle {
cursor: nesw-resize;
.resize-handle:dir(rtl) {
cursor: nesw-resize /* rtl:ignore */;
}

.is-end-aligned .resize-handle {
inset-inline-start: -6px;
inset-inline-end: auto;
cursor: nesw-resize /* rtl:ignore */;
}

.is-end-aligned .resize-handle:dir(rtl) {
cursor: nwse-resize /* rtl:ignore */;
}

.image-node-view.is-selected .resize-handle,
Expand All @@ -391,11 +401,11 @@
transition:
opacity 0.2s,
transform 0.2s ease-in-out;
transform-origin: top right;
transform-origin: top right /* rtl:ignore */;
}

[dir='rtl'] .image-actions {
transform-origin: top left;
.image-actions:dir(rtl) {
transform-origin: top left /* rtl:ignore */;
}

.image-actions.is-compact {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,11 @@
export default defineComponent({
name: 'ToolbarButton',
setup(props, { emit }) {
const isRtl = computed(() => {
return document.dir === 'rtl' || document.documentElement.getAttribute('dir') === 'rtl';
});
const isRtl = window.isRTL;

// Determine which icon to use based on RTL status
const currentIcon = computed(() => {
if (isRtl.value && props.rtlIcon) {
if (isRtl && props.rtlIcon) {
return props.rtlIcon;
}
return props.icon;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ export function useLinkHandling(editor) {

const calculatePosition = (forceCenter = false) => {
if (!editor.value) return {};
const isRTL = document.dir === 'rtl';

isEditorCentered.value = false;
// Only center the edit modal on mobile, not the bubble menu
Expand All @@ -32,7 +31,7 @@ export function useLinkHandling(editor) {
const coords = view.coordsAtPos(from);

// Calculate position based on direction
if (isRTL) {
if (window.isRTL) {
const right = window.innerWidth - coords.left;
return {
position: 'fixed',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/vue';
import VueRouter from 'vue-router';
import TipTapEditor from '../TipTapEditor/TipTapEditor.vue';

async function renderImage(align) {
const alignAttr = align ? ` data-text-align="${align}"` : '';
render(TipTapEditor, {
props: {
value: `<img src="x" width="300"${alignAttr}>`,
mode: 'edit',
format: 'html',
imageProcessor: { ACCEPTED_MIME_TYPES: ['image/png'] },
},
router: new VueRouter(),
});
return screen.findByRole('slider', { name: 'Resize image' });
}

async function dragRight(slider) {
await fireEvent.mouseDown(slider, { clientX: 0 });
await fireEvent.mouseMove(document, { clientX: 20 });
await fireEvent.mouseUp(document);
}

describe('ImageNodeView resize', () => {
afterEach(() => {
delete window.isRTL;
document.documentElement.removeAttribute('dir');
});

it('grows the image when dragged to the right in LTR content', async () => {
const slider = await renderImage();

await dragRight(slider);

await waitFor(() => expect(slider).toHaveAttribute('aria-valuenow', '320'));
});

it('grows the image on ArrowRight in LTR content', async () => {
const slider = await renderImage();

await fireEvent.keyDown(slider, { key: 'ArrowRight' });

await waitFor(() => expect(slider).toHaveAttribute('aria-valuenow', '310'));
});

it('shrinks the image when dragged to the right in RTL content', async () => {
const slider = await renderImage();
slider.style.direction = 'rtl';

await dragRight(slider);

await waitFor(() => expect(slider).toHaveAttribute('aria-valuenow', '280'));
});

it('grows the image on ArrowLeft in RTL content', async () => {
const slider = await renderImage();
slider.style.direction = 'rtl';

await fireEvent.keyDown(slider, { key: 'ArrowLeft' });

await waitFor(() => expect(slider).toHaveAttribute('aria-valuenow', '310'));
});

it('shrinks an end-aligned image when dragged to the right in LTR content', async () => {
const slider = await renderImage('right');

await dragRight(slider);

await waitFor(() => expect(slider).toHaveAttribute('aria-valuenow', '280'));
});

it('grows an end-aligned image when dragged to the right in RTL content', async () => {
const slider = await renderImage('right');
slider.style.direction = 'rtl';

await dragRight(slider);

await waitFor(() => expect(slider).toHaveAttribute('aria-valuenow', '320'));
});

it('grows an end-aligned image on ArrowLeft in LTR content', async () => {
const slider = await renderImage('right');

await fireEvent.keyDown(slider, { key: 'ArrowLeft' });

await waitFor(() => expect(slider).toHaveAttribute('aria-valuenow', '310'));
});

it('grows an end-aligned image on ArrowRight in RTL content', async () => {
const slider = await renderImage('right');
slider.style.direction = 'rtl';

await fireEvent.keyDown(slider, { key: 'ArrowRight' });

await waitFor(() => expect(slider).toHaveAttribute('aria-valuenow', '310'));
});

it('grows a centered image by twice the drag distance', async () => {
const slider = await renderImage('center');

await dragRight(slider);

await waitFor(() => expect(slider).toHaveAttribute('aria-valuenow', '340'));
});

it('follows the content direction, not the page direction', async () => {
window.isRTL = true;
document.documentElement.dir = 'rtl';
const slider = await renderImage();

await dragRight(slider);

await waitFor(() => expect(slider).toHaveAttribute('aria-valuenow', '320'));
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ describe('ToolbarButton', () => {
user = userEvent.setup();
});

afterEach(() => {
delete window.isRTL;
});

it('keeps an unavailable control focusable and marks it aria-disabled', async () => {
const { button } = renderButton({ isAvailable: false });

Expand All @@ -47,4 +51,30 @@ describe('ToolbarButton', () => {

expect(emitted().click).toHaveLength(1);
});

it('renders the RTL icon in RTL', () => {
window.isRTL = true;
renderButton({ rtlIcon: 'bold-rtl.svg' });

expect(screen.getByRole('img', { name: TITLE })).toHaveAttribute('src', 'bold-rtl.svg');
});

it('renders the default icon in LTR', () => {
renderButton({ rtlIcon: 'bold-rtl.svg' });

expect(screen.getByRole('img', { name: TITLE })).toHaveAttribute('src', 'bold.svg');
});

it('does not flip the icon in LTR', () => {
renderButton({ shouldFlipInRtl: true });

expect(screen.getByRole('img', { name: TITLE })).not.toHaveClass('rtl-flip');
});

it('flips the icon in RTL when asked to', () => {
window.isRTL = true;
renderButton({ shouldFlipInRtl: true });

expect(screen.getByRole('img', { name: TITLE })).toHaveClass('rtl-flip');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { render } from '@testing-library/vue';
import { ref } from 'vue';
import VueRouter from 'vue-router';
import { useLinkHandling } from '../TipTapEditor/composables/useLinkHandling';

// jsdom exposes `ontouchstart`, which would otherwise force centered positioning.
jest.mock('shared/utils/browserInfo', () => ({ isTouchDevice: false }));

const COORDS = { left: 100, bottom: 50 };

function createEditor() {
const selection = { from: 1, to: 1, empty: true };
return {
on: () => {},
off: () => {},
state: { selection },
view: { state: { selection }, coordsAtPos: () => COORDS },
};
}

function renderLinkHandling() {
let linkHandling;
render(
{
template: '<div />',
setup() {
linkHandling = useLinkHandling(ref(createEditor()));
},
},
{ router: new VueRouter() },
);
return linkHandling;
}

describe('useLinkHandling', () => {
afterEach(() => {
delete window.isRTL;
});

it('anchors the link editor from the left edge in LTR', () => {
const { openLinkEditor, popoverStyle } = renderLinkHandling();

openLinkEditor();

expect(popoverStyle.value.left).toBe(`${COORDS.left}px`);
expect(popoverStyle.value.right).toBeUndefined();
});

it('anchors the link editor from the right edge in RTL', () => {
window.isRTL = true;
const { openLinkEditor, popoverStyle } = renderLinkHandling();

openLinkEditor();

expect(popoverStyle.value.right).toBe(`${window.innerWidth - COORDS.left}px`);
expect(popoverStyle.value.left).toBeUndefined();
});
});
Loading