diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 000000000..227a2f3de
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,35 @@
+# pie-lib
+
+## What this is
+
+A monorepo of shared React components/utilities for the **PIE (Portable Interactions & Elements)** assessment framework — math rendering/input, drag-and-drop, charting, rich-text editing, config UI, rubric/scoring UI, icons, style utils. Published under the `@pie-lib/*` npm scope.
+
+Consumed by the sibling repo **`pie-elements`** (`../pie-elements`), which installs these as normal npm dependencies (not a local link) — `pie-elements` pins exact versions in its root `package.json` `resolutions` block.
+
+## Repo structure
+
+- Lerna (independent versioning, conventional-commits driven) + Yarn workspaces (`packages/*`).
+- `packages/` — 28 library packages + `demo` (a Next.js app for local preview of all packages, deployed to now.sh on `develop`/`master` merges).
+- Every package: `src/` (with `__tests__/`) → `lib/` (compiled output, checked in).
+- No TypeScript anywhere — plain JS/JSX with PropTypes.
+
+Notable packages: `render-ui` (most widely consumed — preview layout, feedback, collapsible, response indicators), `drag` (dnd-kit based), `math-input`/`math-rendering`/`math-toolbar` (MathQuill, mid-migration to MathLive — see `docs/mathquill-to-mathlive-migration.md`), `charting`/`plot` (visx), `config-ui`, `controller-utils`, `test-utils` (shared test helpers/mocks).
+
+## Commands
+
+- `npm run build` — build all packages
+- `npm test` — run all tests; to test a single package: `./node_modules/.bin/jest packages/pkg-name/src/`
+- `npm run lint` — ESLint
+- `scripts/dev --scope $package-name` — run the demo site on localhost:3000 (`--scope` optional, defaults to all)
+- `npm run release` — release + deploy (merging to `develop` → `next` dist-tag / pie-lib-next.now.sh; merging to `master` → `latest` / pie-lib.now.sh)
+
+## Conventions
+
+- **Conventional commits syntax** on commit messages — Lerna uses this to detect the appropriate independent version bump per package.
+- Styling has migrated to MUI v7 + Emotion (older packages may still show JSS-era patterns).
+- If test setup gets out of sync: `npm run build`, `rm -fr packages/test-utils/node_modules`, then retry.
+- Node >=18 required; there's a known Jest/Node quirk documented in `.cursor/skills/nvm-jest-v22/SKILL.md` (use `nvm use v22` before running jest directly if you hit a syntax error).
+
+## Working preferences
+
+- **Do not create git commits unless explicitly asked.** The user commits their own changes — leave the working tree staged/unstaged as appropriate and let them review and commit themselves.
diff --git a/packages/editable-html-tip-tap/src/components/EditableHtml.jsx b/packages/editable-html-tip-tap/src/components/EditableHtml.jsx
index b6d93144e..13f764c9f 100644
--- a/packages/editable-html-tip-tap/src/components/EditableHtml.jsx
+++ b/packages/editable-html-tip-tap/src/components/EditableHtml.jsx
@@ -243,7 +243,7 @@ export const EditableHtml = (props) => {
let cb;
if (scheduled && result) {
- // finish editing only on success
+ // finish editing only on success
cb = props.onChange;
}
@@ -424,10 +424,12 @@ const StyledEditorContent = styled(EditorContent, {
},
// Out of flow so the caret stays at the start of the block; in-flow ::before pushes the caret after the hint text.
- '& p.is-editor-empty, & div.is-editor-empty': {
+ // :only-child ensures the placeholder is hidden whenever the editor has other content (images, upload nodes, etc.)
+ // and covers the type+backspace edge case where Tiptap only adds is-empty (not is-editor-empty).
+ '& p[data-placeholder].is-empty:only-child, & div[data-placeholder].is-empty:only-child': {
position: 'relative',
},
- '& p.is-editor-empty::before, & div.is-editor-empty::before': {
+ '& p[data-placeholder].is-empty:only-child::before, & div[data-placeholder].is-empty:only-child::before': {
content: 'attr(data-placeholder)',
position: 'absolute',
left: 0,
diff --git a/packages/editable-html-tip-tap/src/components/respArea/DragInTheBlank/__tests__/choice.test.jsx b/packages/editable-html-tip-tap/src/components/respArea/DragInTheBlank/__tests__/choice.test.jsx
new file mode 100644
index 000000000..e572b577d
--- /dev/null
+++ b/packages/editable-html-tip-tap/src/components/respArea/DragInTheBlank/__tests__/choice.test.jsx
@@ -0,0 +1,72 @@
+import React from 'react';
+import { render } from '@testing-library/react';
+import DragDropChoice from '../choice';
+
+// Mock @dnd-kit hooks to avoid DndContext requirement
+jest.mock('@dnd-kit/core', () => ({
+ useDraggable: jest.fn(() => ({
+ attributes: {},
+ listeners: {},
+ setNodeRef: jest.fn(),
+ isDragging: false,
+ })),
+ useDroppable: jest.fn(() => ({
+ setNodeRef: jest.fn(),
+ isOver: false,
+ active: null,
+ })),
+}));
+
+jest.mock('@pie-lib/math-rendering', () => ({
+ renderMath: jest.fn(),
+}));
+
+// Collect the CSS rules emotion/MUI injected into the document (jsdom uses insertRule).
+const collectEmotionRules = () => {
+ const rules = [];
+ for (const sheet of Array.from(document.styleSheets)) {
+ try {
+ for (const rule of Array.from(sheet.cssRules)) {
+ rules.push(rule.cssText);
+ }
+ } catch (e) {
+ /* inaccessible stylesheet */
+ }
+ }
+ return rules;
+};
+
+describe('DragInTheBlank choice', () => {
+ const defaultProps = {
+ value: { id: '1', value: '' },
+ disabled: false,
+ instanceId: 'test-instance',
+ n: { index: 0 },
+ onChange: jest.fn(),
+ removeResponse: jest.fn(),
+ duplicates: false,
+ };
+
+ it('renders without crashing', () => {
+ const { container } = render();
+ expect(container.firstChild).toBeInTheDocument();
+ });
+
+ describe('fraction math styling', () => {
+ it('enlarges numerator/denominator digits adjacent to a fraction to 120%', () => {
+ render();
+ const rule = collectEmotionRules().find((r) => r.includes('mjx-mn') && r.includes('mjx-mfrac'));
+ expect(rule).toBeDefined();
+ expect(rule).toMatch(/mjx-mn:has\(~\s*mjx-mfrac\)/);
+ expect(rule).toMatch(/mjx-mfrac\s*~\s*mjx-mn/);
+ expect(rule).toMatch(/font-size:\s*120%\s*!important/i);
+ });
+
+ it('keeps the existing mjx-frac 120% rule', () => {
+ render();
+ const rule = collectEmotionRules().find((r) => /(^|[^-])mjx-frac/.test(r) && !r.includes('mjx-mfrac'));
+ expect(rule).toBeDefined();
+ expect(rule).toMatch(/font-size:\s*120%\s*!important/i);
+ });
+ });
+});
diff --git a/packages/editable-html-tip-tap/src/components/respArea/DragInTheBlank/choice.jsx b/packages/editable-html-tip-tap/src/components/respArea/DragInTheBlank/choice.jsx
index b7445835c..29bd8f02f 100644
--- a/packages/editable-html-tip-tap/src/components/respArea/DragInTheBlank/choice.jsx
+++ b/packages/editable-html-tip-tap/src/components/respArea/DragInTheBlank/choice.jsx
@@ -10,6 +10,9 @@ import { GripIcon } from '../../icons/RespArea';
const StyledContent = styled('span')(({ theme }) => ({
border: `solid 0px ${theme.palette.primary.main}`,
+ '& mjx-mn:has(~ mjx-mfrac), mjx-mfrac ~ mjx-mn': {
+ fontSize: '120% !important',
+ },
'& mjx-frac': {
fontSize: '120% !important',
},
diff --git a/packages/editable-html-tip-tap/src/extensions/__tests__/image-component.test.jsx b/packages/editable-html-tip-tap/src/extensions/__tests__/image-component.test.jsx
index 84216ceee..6ad818db1 100644
--- a/packages/editable-html-tip-tap/src/extensions/__tests__/image-component.test.jsx
+++ b/packages/editable-html-tip-tap/src/extensions/__tests__/image-component.test.jsx
@@ -39,6 +39,7 @@ describe('ImageComponent', () => {
const createMockEditor = (selection = { from: 0, to: 1 }) => ({
_tiptapContainerEl: document.body,
+ isEditable: true,
commands: {
updateAttributes: jest.fn(),
focus: jest.fn(),
diff --git a/packages/editable-html-tip-tap/src/extensions/__tests__/responseArea.test.js b/packages/editable-html-tip-tap/src/extensions/__tests__/responseArea.test.js
index b1f019c08..26279d0cb 100644
--- a/packages/editable-html-tip-tap/src/extensions/__tests__/responseArea.test.js
+++ b/packages/editable-html-tip-tap/src/extensions/__tests__/responseArea.test.js
@@ -264,16 +264,16 @@ describe('ResponseAreaExtension', () => {
jest.resetModules();
});
- const buildInsertCommand = () => {
+ const buildInsertCommand = (type = 'inline-dropdown') => {
const { ResponseAreaExtension } = require('../responseArea');
const context = {
options: {
- type: 'inline-dropdown',
+ type,
maxResponseAreas: 5,
},
};
const commands = ResponseAreaExtension.addCommands.call(context);
- return commands.insertResponseArea('inline-dropdown');
+ return commands.insertResponseArea(type);
};
const createDoc = (existingCount, typeName = 'inline_dropdown') => ({
@@ -391,6 +391,60 @@ describe('ResponseAreaExtension', () => {
expect(create).not.toHaveBeenCalled();
expect(mockTr.insert).not.toHaveBeenCalled();
});
+
+ describe('selection after insert', () => {
+ const buildStateAndTr = (typeName, { withResolve = false } = {}) => {
+ const mockInlineNode = { nodeSize: 1 };
+ const create = jest.fn(() => mockInlineNode);
+ const mockDoc = {
+ descendants: jest.fn(),
+ content: { size: 50 },
+ ...(withResolve ? { resolve: jest.fn((pos) => ({ resolved: pos })) } : {}),
+ };
+ const mockTr = {
+ insert: jest.fn(),
+ doc: mockDoc,
+ setSelection: jest.fn(),
+ };
+ const state = {
+ schema: { nodes: { [typeName]: { create } } },
+ doc: mockDoc,
+ selection: { from: 5 },
+ };
+
+ return { mockTr, state };
+ };
+
+ it.each(['inline-dropdown', 'explicit-constructed-response'])(
+ 'sets a NodeSelection on the inserted node for %s',
+ (type) => {
+ const insert = buildInsertCommand(type);
+ const typeName = type.replace(/-/g, '_');
+ const { mockTr, state } = buildStateAndTr(typeName);
+ const { NodeSelection, TextSelection } = require('prosemirror-state');
+
+ insert({ tr: mockTr, state, dispatch: jest.fn(), commands: { focus: jest.fn() } });
+
+ expect(NodeSelection.create).toHaveBeenCalledWith(mockTr.doc, 5);
+ expect(TextSelection.near).not.toHaveBeenCalled();
+ expect(mockTr.setSelection).toHaveBeenCalledWith({ type: 'node', pos: 5 });
+ },
+ );
+
+ it('sets a TextSelection just after the inserted node for math-templated (no NodeSelection)', () => {
+ const insert = buildInsertCommand('math-templated');
+ const { mockTr, state } = buildStateAndTr('math_templated', { withResolve: true });
+ const { NodeSelection, TextSelection } = require('prosemirror-state');
+
+ insert({ tr: mockTr, state, dispatch: jest.fn(), commands: { focus: jest.fn() } });
+
+ expect(NodeSelection.create).not.toHaveBeenCalled();
+ // usedPos (5) + nodeSize (1) === 6
+ expect(mockTr.doc.resolve).toHaveBeenCalledWith(6);
+ expect(TextSelection.near).toHaveBeenCalledWith({ resolved: 6 }, 1);
+ expect(mockTr.setSelection).toHaveBeenCalledWith({ type: 'text', pos: { resolved: 6 }, dir: 1 });
+ });
+ });
});
});
});
diff --git a/packages/editable-html-tip-tap/src/extensions/image-component.jsx b/packages/editable-html-tip-tap/src/extensions/image-component.jsx
index 4e5db4e61..7c9d31c21 100644
--- a/packages/editable-html-tip-tap/src/extensions/image-component.jsx
+++ b/packages/editable-html-tip-tap/src/extensions/image-component.jsx
@@ -125,7 +125,7 @@ function ImageComponent(props) {
);
const applySizeData = useCallback(() => {
- if (!node.attrs.width || !imgRef.current) return;
+ if (!node.attrs.width || !imgRef.current || !imgRef.current.naturalWidth) return;
const resizePercent = getPercentFromWidth(node.attrs.width);
if (node.attrs.resizePercent === resizePercent) return;
updateThisNode({ resizePercent });
@@ -138,7 +138,7 @@ function ImageComponent(props) {
useEffect(() => {
if (selected) {
- if (onlyThisNodeSelected) {
+ if (onlyThisNodeSelected && editor.isEditable) {
// Only open the upload UI for a fresh placeholder. Remounting after tab switch
// would otherwise call insertImageRequested again and reopen the file modal.
const hasImageSrc = String(node.attrs?.src ?? '').trim();
@@ -156,15 +156,17 @@ function ImageComponent(props) {
} else {
setShowToolbar(selected);
}
- }, [onlyThisNodeSelected, selected]);
+ }, [onlyThisNodeSelected, selected, editor.isEditable]);
useEffect(() => {
applySizeData();
- const resizeHandle = resizeRef.current;
- if (resizeHandle) {
+ const resizeHandle = resizeRef?.current;
+
+ if (resizeHandle && editor.isEditable) {
resizeHandle.addEventListener('mousedown', initResize, false);
}
+
return () => {
if (resizeHandle) {
resizeHandle.removeEventListener('mousedown', initResize, false);
@@ -272,7 +274,7 @@ function ImageComponent(props) {
onLoad={loadImage}
alt={node.attrs.alt}
/>
-
+
diff --git a/packages/editable-html-tip-tap/src/extensions/responseArea.js b/packages/editable-html-tip-tap/src/extensions/responseArea.js
index 29c722cd6..95d191385 100644
--- a/packages/editable-html-tip-tap/src/extensions/responseArea.js
+++ b/packages/editable-html-tip-tap/src/extensions/responseArea.js
@@ -195,7 +195,13 @@ export const ResponseAreaExtension = Extension.create({
// tr.setSelection(NodeSelection.create(tr.doc, usedPos))
// --- Cursor move behavior for certain types (Slate: moveFocusTo next text) ---
- if (['math_templated', 'inline_dropdown', 'explicit_constructed_response'].includes(typeName)) {
+ // Only types whose node view opens its own toolbar on selection
+ // (inline_dropdown, explicit_constructed_response) benefit from a
+ // NodeSelection here — the toolbar is the visual feedback that it's
+ // selected. math_templated has no such UI, so a NodeSelection there
+ // just looks unfocused (a highlighted node, no blinking caret);
+ // moving the cursor to just after it gives a real, visible caret.
+ if (['inline_dropdown', 'explicit_constructed_response'].includes(typeName)) {
tr.setSelection(NodeSelection.create(tr.doc, usedPos));
} else {
const after = usedPos + newInline.nodeSize;
diff --git a/packages/editable-html/src/plugins/respArea/drag-in-the-blank/__tests__/choice.test.js b/packages/editable-html/src/plugins/respArea/drag-in-the-blank/__tests__/choice.test.js
new file mode 100644
index 000000000..b18e9cfc7
--- /dev/null
+++ b/packages/editable-html/src/plugins/respArea/drag-in-the-blank/__tests__/choice.test.js
@@ -0,0 +1,71 @@
+import React from 'react';
+import { render } from '@testing-library/react';
+import DragDropChoice from '../choice';
+
+// Mock @dnd-kit hooks to avoid DndContext requirement
+jest.mock('@dnd-kit/core', () => ({
+ useDraggable: jest.fn(() => ({
+ attributes: {},
+ listeners: {},
+ setNodeRef: jest.fn(),
+ isDragging: false,
+ })),
+ useDroppable: jest.fn(() => ({
+ setNodeRef: jest.fn(),
+ isOver: false,
+ active: null,
+ })),
+}));
+
+jest.mock('@pie-lib/math-rendering', () => ({
+ renderMath: jest.fn(),
+}));
+
+// Collect the CSS rules emotion/MUI injected into the document (jsdom uses insertRule).
+const collectEmotionRules = () => {
+ const rules = [];
+ for (const sheet of Array.from(document.styleSheets)) {
+ try {
+ for (const rule of Array.from(sheet.cssRules)) {
+ rules.push(rule.cssText);
+ }
+ } catch (e) {
+ /* inaccessible stylesheet */
+ }
+ }
+ return rules;
+};
+
+describe('drag-in-the-blank choice', () => {
+ const defaultProps = {
+ value: { id: '1', value: '' },
+ disabled: false,
+ instanceId: 'test-instance',
+ n: { key: 'key-0' },
+ nodeProps: {},
+ opts: { options: { duplicates: false } },
+ };
+
+ it('renders without crashing', () => {
+ const { container } = render();
+ expect(container.firstChild).toBeInTheDocument();
+ });
+
+ describe('fraction math styling', () => {
+ it('enlarges numerator/denominator digits adjacent to a fraction to 120%', () => {
+ render();
+ const rule = collectEmotionRules().find((r) => r.includes('mjx-mn') && r.includes('mjx-mfrac'));
+ expect(rule).toBeDefined();
+ expect(rule).toMatch(/mjx-mn:has\(~\s*mjx-mfrac\)/);
+ expect(rule).toMatch(/mjx-mfrac\s*~\s*mjx-mn/);
+ expect(rule).toMatch(/font-size:\s*120%\s*!important/i);
+ });
+
+ it('keeps the existing mjx-frac 120% rule', () => {
+ render();
+ const rule = collectEmotionRules().find((r) => /(^|[^-])mjx-frac/.test(r) && !r.includes('mjx-mfrac'));
+ expect(rule).toBeDefined();
+ expect(rule).toMatch(/font-size:\s*120%\s*!important/i);
+ });
+ });
+});
diff --git a/packages/editable-html/src/plugins/respArea/drag-in-the-blank/choice.jsx b/packages/editable-html/src/plugins/respArea/drag-in-the-blank/choice.jsx
index f07948498..f6fe21b77 100644
--- a/packages/editable-html/src/plugins/respArea/drag-in-the-blank/choice.jsx
+++ b/packages/editable-html/src/plugins/respArea/drag-in-the-blank/choice.jsx
@@ -14,6 +14,9 @@ const StyledContent = styled('span')(({ theme }) => ({
'& mjx-frac': {
fontSize: '120% !important',
},
+ '& mjx-mn:has(~ mjx-mfrac), mjx-mfrac ~ mjx-mn': {
+ fontSize: '120% !important',
+ },
'&.chip': {
minWidth: '90px',
},
diff --git a/packages/mask-markup/src/choices/__tests__/index.test.js b/packages/mask-markup/src/choices/__tests__/index.test.js
index 05af4f6e7..e018463d5 100644
--- a/packages/mask-markup/src/choices/__tests__/index.test.js
+++ b/packages/mask-markup/src/choices/__tests__/index.test.js
@@ -4,6 +4,21 @@ import Choice from '../choice';
import { choice } from '../../__tests__/utils';
import Choices from '../index';
+// Collect the CSS rules emotion/MUI injected into the document (jsdom uses insertRule).
+const collectEmotionRules = () => {
+ const rules = [];
+ for (const sheet of Array.from(document.styleSheets)) {
+ try {
+ for (const rule of Array.from(sheet.cssRules)) {
+ rules.push(rule.cssText);
+ }
+ } catch (e) {
+ /* inaccessible stylesheet */
+ }
+ }
+ return rules;
+};
+
// Mock @dnd-kit hooks to avoid DndContext requirement
jest.mock('@dnd-kit/core', () => ({
useDraggable: jest.fn(() => ({
@@ -71,5 +86,24 @@ describe('index', () => {
expect(container.firstChild).toBeInTheDocument();
});
});
+
+ describe('fraction math styling', () => {
+ it('enlarges numerator/denominator digits adjacent to a fraction to 120%', () => {
+ render();
+ // The new rule targets mjx-mn digits that sit next to an mjx-mfrac.
+ const rule = collectEmotionRules().find((r) => r.includes('mjx-mn') && r.includes('mjx-mfrac'));
+ expect(rule).toBeDefined();
+ expect(rule).toMatch(/mjx-mn:has\(~\s*mjx-mfrac\)/);
+ expect(rule).toMatch(/mjx-mfrac\s*~\s*mjx-mn/);
+ expect(rule).toMatch(/font-size:\s*120%\s*!important/i);
+ });
+
+ it('keeps the existing mjx-frac 120% rule', () => {
+ render();
+ const rule = collectEmotionRules().find((r) => /(^|[^-])mjx-frac/.test(r) && !r.includes('mjx-mfrac'));
+ expect(rule).toBeDefined();
+ expect(rule).toMatch(/font-size:\s*120%\s*!important/i);
+ });
+ });
});
});
diff --git a/packages/mask-markup/src/choices/choice.jsx b/packages/mask-markup/src/choices/choice.jsx
index 4629a9cfc..3d3dad64f 100644
--- a/packages/mask-markup/src/choices/choice.jsx
+++ b/packages/mask-markup/src/choices/choice.jsx
@@ -50,6 +50,9 @@ const StyledChipLabel = styled('span')(() => ({
'& mjx-frac': {
fontSize: '120% !important',
},
+ '& mjx-mn:has(~ mjx-mfrac), mjx-mfrac ~ mjx-mn': {
+ fontSize: '120% !important',
+ },
}));
export default function Choice({ choice, disabled, instanceId }) {
@@ -71,8 +74,8 @@ export default function Choice({ choice, disabled, instanceId }) {
style={
isDragging
? {
- width: rootRef.current?.offsetWidth || 90, // min-width of chip is 90px, so if we don't have the width, we can use 90px as a fallback
- height: rootRef.current?.offsetHeight || 32, // min-height of chip is 32px, so if we don't have the height, we can use 32px as a fallback
+ width: rootRef.current?.offsetWidth || 90, // min-width of chip is 90px, so if we don't have the width, we can use 90px as a fallback
+ height: rootRef.current?.offsetHeight || 32, // min-height of chip is 32px, so if we don't have the height, we can use 32px as a fallback
}
: {}
}
diff --git a/packages/mask-markup/src/components/__tests__/blank.test.js b/packages/mask-markup/src/components/__tests__/blank.test.js
index 65954e66d..cb31e1b0e 100644
--- a/packages/mask-markup/src/components/__tests__/blank.test.js
+++ b/packages/mask-markup/src/components/__tests__/blank.test.js
@@ -30,6 +30,21 @@ jest.mock('@pie-lib/math-rendering', () => ({
renderMath: jest.fn(),
}));
+// Collect the CSS rules emotion/MUI injected into the document (jsdom uses insertRule).
+const collectEmotionRules = () => {
+ const rules = [];
+ for (const sheet of Array.from(document.styleSheets)) {
+ try {
+ for (const rule of Array.from(sheet.cssRules)) {
+ rules.push(rule.cssText);
+ }
+ } catch (e) {
+ /* inaccessible stylesheet */
+ }
+ }
+ return rules;
+};
+
describe('Blank', () => {
const { renderMath } = require('@pie-lib/math-rendering');
const onChange = jest.fn();
@@ -184,6 +199,24 @@ describe('Blank', () => {
});
});
+ describe('fraction math styling', () => {
+ it('enlarges numerator/denominator digits adjacent to a fraction to 120%', () => {
+ render();
+ const rule = collectEmotionRules().find((r) => r.includes('mjx-mn') && r.includes('mjx-mfrac'));
+ expect(rule).toBeDefined();
+ expect(rule).toMatch(/mjx-mn:has\(~\s*mjx-mfrac\)/);
+ expect(rule).toMatch(/mjx-mfrac\s*~\s*mjx-mn/);
+ expect(rule).toMatch(/font-size:\s*120%\s*!important/i);
+ });
+
+ it('keeps the existing mjx-frac 120% rule', () => {
+ render();
+ const rule = collectEmotionRules().find((r) => /(^|[^-])mjx-frac/.test(r) && !r.includes('mjx-mfrac'));
+ expect(rule).toBeDefined();
+ expect(rule).toMatch(/font-size:\s*120%\s*!important/i);
+ });
+ });
+
describe('drag and drop', () => {
it('accepts drag item when not disabled', () => {
render();
diff --git a/packages/mask-markup/src/components/blank.jsx b/packages/mask-markup/src/components/blank.jsx
index 5c42afae8..1828391c4 100644
--- a/packages/mask-markup/src/components/blank.jsx
+++ b/packages/mask-markup/src/components/blank.jsx
@@ -76,6 +76,9 @@ const StyledChipLabel = styled('span')(() => ({
'& mjx-frac': {
fontSize: '120% !important',
},
+ '& mjx-mn:has(~ mjx-mfrac), mjx-mfrac ~ mjx-mn': {
+ fontSize: '120% !important',
+ },
'&.over': {
whiteSpace: 'nowrap',
overflow: 'hidden',
@@ -125,11 +128,25 @@ function BlankContent({
};
const getMeasureNode = () => {
- if (!spanRef.current) return null;
+ if (!spanRef.current) {
+ return null;
+ }
+
const mjx = spanRef.current.querySelector('mjx-container');
- if (mjx && spanRef.current.parentElement) return spanRef.current.parentElement;
+
+ if (mjx && spanRef.current.parentElement) {
+ return spanRef.current.parentElement;
+ }
+
const img = spanRef.current.querySelector('img');
- if (img) return img;
+
+ if (img) {
+ // If there's text alongside the image, measure the full span to capture both dimensions
+ const hasTextContent = spanRef.current.textContent.trim().length > 0;
+
+ return hasTextContent ? spanRef.current : img;
+ }
+
return spanRef.current;
};
@@ -166,7 +183,6 @@ function BlankContent({
const adjustedWidth = widthWithPadding <= responseAreaWidth ? responseAreaWidth : widthWithPadding;
const adjustedHeight = heightWithPadding <= responseAreaHeight ? responseAreaHeight : heightWithPadding;
-
setDimensions((prevState) => ({
width: adjustedWidth > responseAreaWidth ? adjustedWidth : prevState.width,
height: adjustedHeight > responseAreaHeight ? adjustedHeight : prevState.height,