Skip to content
Merged
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 @@ -67,6 +67,7 @@
@click.self="imageHandler.closeModal"
>
<ImageUploadModal
ref="imageUploadModal"
:style="imageHandler.popoverStyle.value"
:mode="imageHandler.modalMode.value"
:initial-data="imageHandler.modalInitialData.value"
Expand All @@ -84,6 +85,7 @@
@click.self="mathHandler.closeMathModal()"
>
<FormulasMenu
ref="formulasMenu"
:style="mathHandler.popoverStyle.value"
:mode="mathHandler.mathModalMode.value"
:initial-latex="mathHandler.mathModalInitialLatex.value"
Expand Down Expand Up @@ -152,13 +154,19 @@
const linkHandler = useLinkHandling(editor);
provide('linkHandler', linkHandler);

// The anchored modals are measured and hit-tested through these refs, so that several
// editors mounted at once each work with their own modal.
const imageUploadModal = ref(null);
const formulasMenu = ref(null);

const mathHandler = useMathHandling(
editor,
computed(() => 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(() => ({
Expand Down Expand Up @@ -277,6 +285,8 @@

return {
editorContainer,
imageUploadModal,
formulasMenu,
isReady,
isFocused,
handleDrop,
Expand Down Expand Up @@ -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;

@rtibblesbot rtibblesbot Aug 27, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved — addressed in the current code.

nitpick: 8 reads as arbitrary next to .has-overlay { z-index: 10 } below and z-index: 1000 in FormatDropdown.vue:252 / PasteDropdown.vue:321 — dropdowns in the same toolbar and stacking context. Which KDS guidance is 8 from, and should those two move to it too? A one-line comment naming the layer would stop the next person nudging it.

width: 100%;
height: 100%;
pointer-events: none;
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -14,7 +19,7 @@ export function useImageHandling(editor) {
closeModal: closeModalBase,
setupClickOutside,
cleanup,
} = useModalPositioning();
} = useModalPositioning(getModalElement);

const closeModal = () => {
modalMode.value = null;
Expand All @@ -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 };
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Height is measured once at open (plus scroll/resize), but .image-upload-modal changes height while open: it opens in the drop-zone state (ImageUploadModal.vue:91-110, ~290px) and switches to the preview state on file selection (ImageUploadModal.vue:46-90), adding a file-info row, a 150px preview container and the alt-text field — roughly +155px.

At 1280x720 with the anchor at y=400: highestTop = 720 - 290 - 8 = 422, so top stays 405; after the growth the footer ends around 850px and the Insert button is off-screen — the same failure this PR fixes for formulas.

A ResizeObserver on the measured element, started on open and disconnected in cleanup, covers this and the scroll/resize path in one mechanism, without consumers having to know.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The natural instinct would be to scroll, and scrolling would fix this. If the container is non-scrollable, that'd be a problem, but right now a non-scrollable container would only happen on a large screen, and a large screen would have enough space to show the new height. We should use KDS components for these floating components once KDS is ready, so I don't want to make this more complex if it will probably be replaced by a more robust approach anyways.

// 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',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Only the vertical axis is clamped. left: rect.right + translateX(-100%) puts the left edge at rect.right - modalWidth; .formulas-menu resolves to min(90vw, 500px) against the viewport, so an anchor whose right edge is under 500px from the viewport's left edge pushes the menu off-screen — and there is no scroll escape horizontally.

This bites hardest in RTL, where the toolbar mirrors so buttons that sit far from the left edge in LTR sit near it, and translateX(-100%) is script-built so RTLCSS cannot flip it. A symmetric clamp in the same expression closes it:

const left = Math.min(Math.max(rect.right, modalWidth + VIEWPORT_MARGIN), window.innerWidth - VIEWPORT_MARGIN);

If it is deliberately out of scope, worth a QA check at ~360px/~768px in an RTL locale rather than a code change.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At that screen size, we don't render a popup but a modal.

top: `${rect.bottom + 5}px`,
top: `${top}px`,
left: `${rect.right}px`,
transform: 'translateX(-100%)',
};
Expand Down Expand Up @@ -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 = () => {
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
Loading