diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue index 0e114c49ea..b218ed35fb 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue @@ -67,6 +67,7 @@ @click.self="imageHandler.closeModal" > props.mode), + () => formulasMenu.value?.$el, ); provide('mathHandler', mathHandler); - const imageHandler = useImageHandling(editor); + const imageHandler = useImageHandling(editor, () => imageUploadModal.value?.$el); provide('imageProcessor', props.imageProcessor); const sharedEventHandlers = computed(() => ({ @@ -277,6 +285,8 @@ return { editorContainer, + imageUploadModal, + formulasMenu, isReady, isFocused, handleDrop, @@ -365,7 +375,10 @@ position: fixed; top: 0; left: 0; - z-index: 2; + + /* KDS uses the Material elevation dp values as default z-indexes, and menus and popovers + sit at 8dp: https://design-system.learningequality.org/styling/#z-indexes */ + z-index: 8; width: 100%; height: 100%; pointer-events: none; diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useImageHandling.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useImageHandling.js index 86362245e7..6f232e82ec 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useImageHandling.js +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useImageHandling.js @@ -1,7 +1,12 @@ import { ref, onMounted, onUnmounted } from 'vue'; import { useModalPositioning } from './useModalPositioning'; -export function useImageHandling(editor) { +/** + * @param {Object} editor + * @param {Function} getModalElement returns the element of this editor's image upload modal, + * or a nullish value while it is not rendered. + */ +export function useImageHandling(editor, getModalElement) { const modalMode = ref(null); const modalInitialData = ref({}); const editingNodePos = ref(null); @@ -14,7 +19,7 @@ export function useImageHandling(editor) { closeModal: closeModalBase, setupClickOutside, cleanup, - } = useModalPositioning(); + } = useModalPositioning(getModalElement); const closeModal = () => { modalMode.value = null; @@ -24,7 +29,7 @@ export function useImageHandling(editor) { editor.value?.commands.focus(); }; - setupClickOutside('.image-upload-modal', closeModal); + setupClickOutside(closeModal); const openCreateModal = ({ file = null, targetElement = null } = {}) => { modalInitialData.value = { file }; diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useMathHandling.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useMathHandling.js index 9c4aa1f63d..8a6923ee5f 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useMathHandling.js +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useMathHandling.js @@ -1,7 +1,13 @@ import { ref, onUnmounted, watch, nextTick } from 'vue'; import { useModalPositioning } from './useModalPositioning'; -export function useMathHandling(editor, editorMode) { +/** + * @param {Object} editor + * @param {Object} editorMode + * @param {Function} getModalElement returns the element of this editor's formulas menu, or a + * nullish value while it is not rendered. + */ +export function useMathHandling(editor, editorMode, getModalElement) { const mathModalMode = ref('create'); const mathModalInitialLatex = ref(''); const editingMathNodePos = ref(null); @@ -14,14 +20,14 @@ export function useMathHandling(editor, editorMode) { closeModal: closeModalBase, setupClickOutside, cleanup, - } = useModalPositioning(); + } = useModalPositioning(getModalElement); const closeMathModal = () => { closeModalBase(); editor.value?.commands.focus(); }; - setupClickOutside('.formulas-menu', closeMathModal); + setupClickOutside(closeMathModal); const openCreateMathModal = ({ targetElement = null } = {}) => { mathModalMode.value = 'create'; diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useModalPositioning.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useModalPositioning.js index 861eed1df5..6f2671e881 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useModalPositioning.js +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useModalPositioning.js @@ -1,8 +1,18 @@ -import { ref, watch } from 'vue'; +import { nextTick, ref, watch } from 'vue'; import throttle from 'lodash/throttle'; import { isTouchDevice } from 'shared/utils/browserInfo'; -export function useModalPositioning() { +// Gap between the anchor and the modal, and the smallest gap kept to the viewport edges. +const ANCHOR_GAP = 5; +const VIEWPORT_MARGIN = 8; + +/** + * @param {Function} getModalElement returns the element of the modal being positioned, or a + * nullish value while the modal is not rendered. It is used to measure the modal, so that an + * anchored modal can be kept inside the viewport, and to tell clicks inside it from clicks + * outside of it. + */ +export function useModalPositioning(getModalElement) { const isModalOpen = ref(false); const popoverStyle = ref({}); const isModalCentered = ref(false); @@ -13,9 +23,19 @@ export function useModalPositioning() { return; } const rect = anchorElement.value.getBoundingClientRect(); + // The modal is fixed-positioned, so overflow past the bottom of the viewport cannot be + // scrolled into view: pull the modal up by the overflowing amount instead. When the modal is + // taller than the viewport, it is aligned to the top so that its beginning stays visible. + const modalHeight = getModalElement()?.offsetHeight || 0; + const highestTop = window.innerHeight - modalHeight - VIEWPORT_MARGIN; + + // Choose the top position that is at least VIEWPORT_MARGIN from the top of the viewport, + // and at most the bottom of the anchor element plus ANCHOR_GAP, + // but not overflowing past the bottom of the viewport. + const top = Math.max(VIEWPORT_MARGIN, Math.min(rect.bottom + ANCHOR_GAP, highestTop)); popoverStyle.value = { position: 'fixed', - top: `${rect.bottom + 5}px`, + top: `${top}px`, left: `${rect.right}px`, transform: 'translateX(-100%)', }; @@ -61,6 +81,10 @@ export function useModalPositioning() { setAnchoredPosition(targetElement); } isModalOpen.value = true; + if (!isModalCentered.value) { + // The modal only renders once it is open, so re-position it once its height is known. + nextTick(updatePosition); + } }; const closeModal = () => { @@ -69,9 +93,9 @@ export function useModalPositioning() { anchorElement.value = null; }; - const setupClickOutside = (modalSelector, closeFunction) => { + const setupClickOutside = closeFunction => { const clickOutsideHandler = event => { - const modalElement = document.querySelector(modalSelector); + const modalElement = getModalElement(); if (isModalOpen.value && modalElement && !modalElement.contains(event.target)) { // Allow the consumer to do its own cleanup. closeFunction(); diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/useModalPositioning.spec.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/useModalPositioning.spec.js new file mode 100644 index 0000000000..c093a0053b --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/useModalPositioning.spec.js @@ -0,0 +1,136 @@ +import { nextTick } from 'vue'; +import { useModalPositioning } from '../TipTapEditor/composables/useModalPositioning'; + +// jsdom exposes `ontouchstart`, which would otherwise force centered positioning. +jest.mock('shared/utils/browserInfo', () => ({ isTouchDevice: false })); + +const VIEWPORT_HEIGHT = 500; + +/** + * jsdom does not lay out elements, so anchor and modal geometry has to be faked. + */ +function createAnchor({ bottom, right = 300 }) { + const anchor = document.createElement('button'); + anchor.getBoundingClientRect = () => ({ bottom, right, top: bottom - 20, left: right - 40 }); + document.body.appendChild(anchor); + return anchor; +} + +function createModal(height) { + const modal = document.createElement('div'); + Object.defineProperty(modal, 'offsetHeight', { value: height }); + document.body.appendChild(modal); + return modal; +} + +describe('useModalPositioning', () => { + beforeEach(() => { + document.body.innerHTML = ''; + window.innerHeight = VIEWPORT_HEIGHT; + }); + + it('anchors the modal below the target when it fits in the viewport', async () => { + const modal = createModal(100); + const { openModal, popoverStyle } = useModalPositioning(() => modal); + + openModal({ targetElement: createAnchor({ bottom: 100 }) }); + await nextTick(); + + expect(popoverStyle.value.top).toBe('105px'); + expect(popoverStyle.value.left).toBe('300px'); + }); + + it('pulls the modal up by the overflowing amount when it would overflow the bottom', async () => { + const modal = createModal(200); + const { openModal, popoverStyle } = useModalPositioning(() => modal); + + // Below the anchor the modal would end at 400 + 5 + 200 = 605px, past the 500px viewport. + openModal({ targetElement: createAnchor({ bottom: 400 }) }); + await nextTick(); + + expect(popoverStyle.value.top).toBe(`${VIEWPORT_HEIGHT - 200 - 8}px`); + }); + + it('keeps the modal within the top of the viewport when it is taller than the viewport', async () => { + const modal = createModal(VIEWPORT_HEIGHT + 200); + const { openModal, popoverStyle } = useModalPositioning(() => modal); + + openModal({ targetElement: createAnchor({ bottom: 400 }) }); + await nextTick(); + + expect(popoverStyle.value.top).toBe('8px'); + }); + + it('measures the modal again once it has been rendered', async () => { + // The modal only renders once it is open, so there is nothing to measure while opening it. + let modal = null; + const { openModal, popoverStyle } = useModalPositioning(() => modal); + + openModal({ targetElement: createAnchor({ bottom: 400 }) }); + expect(popoverStyle.value.top).toBe('405px'); + + modal = createModal(200); + await nextTick(); + + expect(popoverStyle.value.top).toBe(`${VIEWPORT_HEIGHT - 200 - 8}px`); + }); + + it('leaves centered positioning untouched', async () => { + const modal = createModal(200); + const { openModal, popoverStyle, isModalCentered } = useModalPositioning(() => modal); + + openModal({ centered: true, targetElement: createAnchor({ bottom: 400 }) }); + await nextTick(); + + expect(isModalCentered.value).toBe(true); + expect(popoverStyle.value.top).toBe('50%'); + }); + + describe('setupClickOutside', () => { + let closeOpenedModal; + + beforeEach(() => { + jest.useFakeTimers(); + closeOpenedModal = () => {}; + }); + + afterEach(async () => { + // Closing the modal detaches the listener the composable added to the document. + closeOpenedModal(); + await nextTick(); + jest.useRealTimers(); + }); + + async function openModalWithClickOutside() { + const modal = createModal(100); + const anchor = createAnchor({ bottom: 100 }); + const closeFunction = jest.fn(); + const { openModal, closeModal, setupClickOutside } = useModalPositioning(() => modal); + setupClickOutside(closeFunction); + + openModal({ targetElement: anchor }); + closeOpenedModal = closeModal; + // The listener is attached on the tick after the modal opens, on a zero timeout. + await nextTick(); + jest.runAllTimers(); + + return { modal, anchor, closeFunction }; + } + + it('closes the modal when the click lands outside of it', async () => { + const { anchor, closeFunction } = await openModalWithClickOutside(); + + anchor.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + + expect(closeFunction).toHaveBeenCalled(); + }); + + it('leaves the modal open when the click lands inside of it', async () => { + const { modal, closeFunction } = await openModalWithClickOutside(); + + modal.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + + expect(closeFunction).not.toHaveBeenCalled(); + }); + }); +});