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
10 changes: 5 additions & 5 deletions packages/controller-utils/src/__tests__/persistence.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,9 @@ describe('lockChoices', () => {
${false} | ${undefined} | ${undefined} | ${false}
${undefined} | ${session()} | ${env(false)} | ${false}
${undefined} | ${session()} | ${env(undefined)} | ${false}
${false} | ${session()} | ${env(false, 'instructor')} | ${true}
${false} | ${session([0, 1])} | ${env(false, 'instructor')} | ${true}
${false} | ${undefined} | ${env(false, 'instructor')} | ${true}
${false} | ${session()} | ${env(false, 'instructor')} | ${false}
${false} | ${session([0, 1])} | ${env(false, 'instructor')} | ${false}
${false} | ${undefined} | ${env(false, 'instructor')} | ${false}
`('1. model.lockChoiceOrder: $modelLock, $session, $env => $expected', ({ modelLock, session, env, expected }) => {
const model = { lockChoiceOrder: modelLock };
const result = lockChoices(model, session, env);
Expand All @@ -120,7 +120,7 @@ describe('lockChoices mod', () => {
${false} | ${session()} | ${env(false)} | ${false}
${undefined} | ${session()} | ${env(true)} | ${true}
${undefined} | ${session()} | ${env(undefined)} | ${false}
${undefined} | ${session()} | ${env(undefined, 'instructor')} | ${true}
${undefined} | ${session()} | ${env(undefined, 'instructor')} | ${false}
`('2. model.lockChoiceOrder: $modelLock, $session, $env => $expected', ({ modelLock, session, env, expected }) => {
const model = { lockChoiceOrder: modelLock };
const result = lockChoices(model, session, env);
Expand All @@ -142,7 +142,7 @@ describe('lockChoices', () => {
${false} | ${session()} | ${env(false)} | ${false}
${undefined} | ${session()} | ${env(true)} | ${true}
${undefined} | ${session()} | ${env(undefined)} | ${false}
${undefined} | ${session()} | ${env(undefined, 'instructor')} | ${true}
${undefined} | ${session()} | ${env(undefined, 'instructor')} | ${false}
`('3. model.lockChoiceOrder: $modelLock, $env => $expected', ({ modelLock, session, env, expected }) => {
const model = { lockChoiceOrder: modelLock };
const result = lockChoices(model, session, env);
Expand Down
19 changes: 4 additions & 15 deletions packages/controller-utils/src/persistence.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ export const getShuffledChoices = (choices, session, updateSession, choiceKey) =
* - true - that means that the order of the choices will be ordinal (as is created in the configure item)
* - false - that means the getShuffledChoices above will be called and that in turn means that we either
* return the shuffled values on the session (if any exists) or we shuffle the choices
*
* Note: the role (student/instructor) is intentionally not considered here — instructor mode
* will respect the same `lockChoiceOrder` value as students, instead of forcing the order to be locked.
*
* @param model - model to check if we should lock order
* @param session - session to check if we should lock order
* @param env - env to check if we should lock order
Expand All @@ -85,20 +89,5 @@ export const lockChoices = (model, session, env) => {
return true;
}

const role = get(env, 'role', 'student');

if (role === 'instructor') {
// TODO: .. in the future the instructor can toggle between ordinal and shuffled here, so keeping this code until then
/*const alreadyShuffled = hasShuffledValues(session);

if (alreadyShuffled) {
return false;
}

return true;*/
return true;
}

// here it's a student, so don't lock and it will shuffle if needs be
return false;
};
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,7 @@ export const EditableHtml = (props) => {
const nextMarkup = normalizeInitialMarkup(props.markup);

if (nextMarkup !== editor.getHTML()) {
editor.commands.setContent(nextMarkup, false);
editor.commands.setContent(nextMarkup, { emitUpdate: false });
}
}, [props.markup, editor]);

Expand Down
155 changes: 146 additions & 9 deletions packages/editable-html-tip-tap/src/extensions/__tests__/math.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import { render, waitFor, fireEvent } from '@testing-library/react';
import { render, waitFor, fireEvent, act } from '@testing-library/react';
import { EnsureTextAfterMathPlugin, MathNode, MathNodeView, ZeroWidthSpaceHandlingPlugin } from '../math';
import * as toolbarUtils from '../../utils/toolbar';

Expand Down Expand Up @@ -390,6 +390,7 @@ describe('MathNodeView', () => {
selection: {
from: 0,
to: 1,
node: { type: { name: 'math' } },
},
tr: {
setSelection: jest.fn().mockReturnThis(),
Expand Down Expand Up @@ -467,30 +468,31 @@ describe('MathNodeView', () => {
});

describe('toolbar positioning', () => {
it('positions relative to the editor element using coordsAtPos', async () => {
it('positions relative to portal container using coordsAtPos', async () => {
const { container } = render(<MathNodeView {...defaultProps} selected={true} />);
await waitFor(() => {
const toolbar = container.querySelector('[data-toolbar-for]');
expect(toolbar).toBeInTheDocument();
expect(toolbar.style.top).toBe('140px');
expect(toolbar.style.top).toBe('100px');
expect(toolbar.style.left).toBe('50px');
});
});

it('accounts for editor scroll offset when calculating toolbar position', async () => {
const editorElement = createEditorElement({ top: -200, left: 0, width: 600, height: 400 });
it('offsets position by portal container getBoundingClientRect', async () => {
const containerEl = document.createElement('div');
containerEl.getBoundingClientRect = jest.fn(() => ({ top: 100, left: 50, width: 600, height: 400 }));

const editor = {
...defaultProps.editor,
options: { element: editorElement },
_tiptapContainerEl: containerEl,
};

const { container } = render(<MathNodeView {...defaultProps} editor={editor} selected={true} />);
await waitFor(() => {
const toolbar = container.querySelector('[data-toolbar-for]');
expect(toolbar).toBeInTheDocument();
expect(toolbar.style.top).toBe('340px');
expect(toolbar.style.left).toBe('50px');
expect(toolbar.style.top).toBe('0px');
expect(toolbar.style.left).toBe('0px');
});
});

Expand All @@ -503,6 +505,15 @@ describe('MathNodeView', () => {
});
});

it('renders above other editor overlays with a high z-index', async () => {
const { container } = render(<MathNodeView {...defaultProps} selected={true} />);
await waitFor(() => {
const toolbar = container.querySelector('[data-toolbar-for]');
expect(toolbar).toBeInTheDocument();
expect(toolbar.style.zIndex).toBe('1000');
});
});

it('updates position from coordsAtPos when selection changes', async () => {
const editor = {
...defaultProps.editor,
Expand All @@ -517,11 +528,64 @@ describe('MathNodeView', () => {
await waitFor(() => {
const toolbar = container.querySelector('[data-toolbar-for]');
expect(toolbar).toBeInTheDocument();
expect(toolbar.style.top).toBe('240px');
expect(toolbar.style.top).toBe('200px');
expect(toolbar.style.left).toBe('150px');
});
});

it('clamps toolbar position to viewport margins', async () => {
const originalInnerHeight = window.innerHeight;
const originalInnerWidth = window.innerWidth;

Object.defineProperty(window, 'innerHeight', { configurable: true, writable: true, value: 200 });
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: 300 });

const editor = {
...defaultProps.editor,
view: {
...defaultProps.editor.view,
coordsAtPos: jest.fn(() => ({ top: 190, left: 280, bottom: 195 })),
dispatch: jest.fn(),
},
};

const { container } = render(<MathNodeView {...defaultProps} editor={editor} selected={true} />);

let toolbar;
await waitFor(() => {
toolbar = container.querySelector('[data-toolbar-for]');
expect(toolbar).toBeInTheDocument();
});

Object.defineProperty(toolbar, 'offsetHeight', { configurable: true, value: 100 });
Object.defineProperty(toolbar, 'offsetWidth', { configurable: true, value: 150 });

await act(async () => {
window.dispatchEvent(new Event('resize'));
await new Promise((resolve) => requestAnimationFrame(resolve));
});

await waitFor(() => {
expect(parseInt(toolbar.style.top, 10)).toBeLessThanOrEqual(200 - 100 - 8);
expect(parseInt(toolbar.style.left, 10)).toBeLessThanOrEqual(300 - 150 - 8);
expect(parseInt(toolbar.style.top, 10)).toBeGreaterThanOrEqual(8);
expect(parseInt(toolbar.style.left, 10)).toBeGreaterThanOrEqual(8);
});

Object.defineProperty(window, 'innerHeight', { configurable: true, writable: true, value: originalInnerHeight });
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: originalInnerWidth });
});

it('attaches scroll and resize listeners while toolbar is open', async () => {
const addSpy = jest.spyOn(window, 'addEventListener');
render(<MathNodeView {...defaultProps} selected={true} />);
await waitFor(() => {
expect(addSpy).toHaveBeenCalledWith('scroll', expect.any(Function), true);
expect(addSpy).toHaveBeenCalledWith('resize', expect.any(Function));
});
addSpy.mockRestore();
});

it('portals toolbar into _tiptapContainerEl when available', async () => {
const containerEl = document.createElement('div');
containerEl.getBoundingClientRect = jest.fn(() => ({ top: 0, left: 0, width: 600, height: 400 }));
Expand Down Expand Up @@ -648,6 +712,31 @@ describe('MathNodeView', () => {
});
});

it('re-registers click listener when node changes', async () => {
const addEventListenerSpy = jest.spyOn(document, 'addEventListener');
const removeEventListenerSpy = jest.spyOn(document, 'removeEventListener');
const nodeA = { attrs: { latex: 'x^2' } };
const nodeB = { attrs: { latex: 'y^2' } };

const { rerender } = render(<MathNodeView {...defaultProps} node={nodeA} selected={true} />);

await waitFor(() => {
expect(addEventListenerSpy).toHaveBeenCalledWith('click', expect.any(Function));
});

const initialCallCount = addEventListenerSpy.mock.calls.length;

rerender(<MathNodeView {...defaultProps} node={nodeB} selected={true} />);

await waitFor(() => {
expect(removeEventListenerSpy).toHaveBeenCalled();
expect(addEventListenerSpy.mock.calls.length).toBeGreaterThan(initialCallCount);
});

addEventListenerSpy.mockRestore();
removeEventListenerSpy.mockRestore();
});

it('does not close toolbar when clicking the math node preview', async () => {
const { getByTestId, queryByTestId } = render(<MathNodeView {...defaultProps} selected={true} />);

Expand Down Expand Up @@ -724,6 +813,54 @@ describe('MathNodeView', () => {
});
});

describe('selection-based toolbar guard', () => {
it('opens toolbar when selected transitions to true and the editor has a NodeSelection on math', async () => {
const { queryByTestId, rerender } = render(<MathNodeView {...defaultProps} selected={false} />);
expect(queryByTestId('math-toolbar')).not.toBeInTheDocument();

rerender(<MathNodeView {...defaultProps} selected={true} />);
await waitFor(() => {
expect(queryByTestId('math-toolbar')).toBeInTheDocument();
});
});

it('does not open toolbar when selected briefly becomes true but editor selection has no node (Cmd+A / drag case)', async () => {
const editor = {
...defaultProps.editor,
state: {
...defaultProps.editor.state,
selection: { from: 0, to: 100 }, // no .node — TextSelection / AllSelection shape
},
};

const { queryByTestId, rerender } = render(
<MathNodeView {...defaultProps} editor={editor} selected={false} />,
);
rerender(<MathNodeView {...defaultProps} editor={editor} selected={true} />);

await act(async () => {});
expect(queryByTestId('math-toolbar')).not.toBeInTheDocument();
});

it('does not open toolbar when selected briefly becomes true but NodeSelection targets a non-math node', async () => {
const editor = {
...defaultProps.editor,
state: {
...defaultProps.editor.state,
selection: { from: 0, to: 1, node: { type: { name: 'image' } } },
},
};

const { queryByTestId, rerender } = render(
<MathNodeView {...defaultProps} editor={editor} selected={false} />,
);
rerender(<MathNodeView {...defaultProps} editor={editor} selected={true} />);

await act(async () => {});
expect(queryByTestId('math-toolbar')).not.toBeInTheDocument();
});
});

it('does not close toolbar when clicking equation editor dropdown', async () => {
const { queryByTestId } = render(<MathNodeView {...defaultProps} selected={true} />);

Expand Down
19 changes: 16 additions & 3 deletions packages/editable-html-tip-tap/src/extensions/css.js
Original file line number Diff line number Diff line change
Expand Up @@ -169,14 +169,27 @@ export const CSSMark = Mark.create({
},

parseHTML() {
// Any span with a class that matches one of allowed classes
// Any span with a class that matches one of allowed classes any span that carries a class attribute
// so that pre-existing spans are preserved when loading content
return [
{
tag: 'span[class]',
getAttrs: (el) => {
const cls = el.getAttribute('class') || '';
const match = this.options.classes.find((name) => cls.includes(name));
return match ? { class: match } : false;

if (!cls) {
return false;
}

const allowedClasses = (this.options && this.options.classes) || [];

if (allowedClasses.length > 0) {
const match = this.options.classes.find((name) => cls.includes(name));

return match ? { class: match } : false;
}

return { class: cls };
},
},
];
Expand Down
Loading
Loading