diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/image/ImageNodeView.vue b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/image/ImageNodeView.vue
index d89124f2c8..c69f587c79 100644
--- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/image/ImageNodeView.vue
+++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/image/ImageNodeView.vue
@@ -4,7 +4,7 @@
![]()
{
- // 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);
@@ -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);
@@ -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
@@ -316,6 +315,7 @@
removeImage,
editImage,
isCompact,
+ isEndAligned,
onResizeKeyDown,
onImageLoad,
};
@@ -359,7 +359,7 @@
bottom: -6px;
width: 24px;
height: 24px;
- cursor: nwse-resize;
+ cursor: nwse-resize /* rtl:ignore */;
background: white;
border: 2px solid #666666;
border-radius: 50%;
@@ -367,8 +367,18 @@
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,
@@ -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 {
diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/toolbar/ToolbarButton.vue b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/toolbar/ToolbarButton.vue
index 32b9021a6f..14658e1300 100644
--- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/toolbar/ToolbarButton.vue
+++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/toolbar/ToolbarButton.vue
@@ -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;
diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useLinkHandling.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useLinkHandling.js
index 9cd5670b4d..67b017c552 100644
--- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useLinkHandling.js
+++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useLinkHandling.js
@@ -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
@@ -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',
diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/ImageNodeView.spec.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/ImageNodeView.spec.js
new file mode 100644
index 0000000000..6d4e1fe54e
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/ImageNodeView.spec.js
@@ -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: `

`,
+ 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'));
+ });
+});
diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/ToolbarButton.spec.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/ToolbarButton.spec.js
index a5eda7b96e..84a010c4de 100644
--- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/ToolbarButton.spec.js
+++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/ToolbarButton.spec.js
@@ -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 });
@@ -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');
+ });
});
diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/useLinkHandling.spec.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/useLinkHandling.spec.js
new file mode 100644
index 0000000000..5cf7abd531
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/useLinkHandling.spec.js
@@ -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: '
',
+ 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();
+ });
+});