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(); + }); +});