- {children}
-
- );
-}
-
-MiewViewer.defaultProps = {
- frozen: false,
-};
diff --git a/packages/miew-app/src/components/viewport/MiewViewport.jsx b/packages/miew-app/src/components/viewport/MiewViewport.jsx
new file mode 100644
index 00000000..3b29c188
--- /dev/null
+++ b/packages/miew-app/src/components/viewport/MiewViewport.jsx
@@ -0,0 +1,98 @@
+import React, {
+ useEffect, useMemo, useCallback, useState,
+} from 'react';
+import Viewer from 'miew-react';
+import { MiewProvider } from '../../contexts/MiewContext';
+
+export default function MiewViewport({
+ frozen, onChange, updateLoadingStage, children,
+}) {
+ const [viewer, setViewer] = useState(null);
+
+ const handlePrefsChange = useCallback((prefs) => {
+ onChange({ prefs });
+ }, [onChange]);
+
+ const options = useMemo(() => ({
+ load: '1crn',
+ settings: {
+ axes: false,
+ fps: false,
+ },
+ }), []);
+
+ const handleInit = useCallback((miew) => {
+ window.miew = miew;
+ miew.logger.level = 'debug';
+ setViewer(miew);
+ onChange({ viewer: miew });
+ }, [onChange]);
+
+ const handleError = useCallback(() => {
+ updateLoadingStage('Failed to initialize viewer');
+ }, [updateLoadingStage]);
+
+ useEffect(() => {
+ if (!viewer) {
+ return undefined;
+ }
+
+ const handleFetching = () => updateLoadingStage('Fetching...');
+ const handleParsing = () => updateLoadingStage('Parsing...');
+ const handleRebuilding = () => updateLoadingStage('Building geometry...');
+ const handleTitleChanged = (e) => updateLoadingStage(e.data);
+
+ viewer.addEventListener('fetching', handleFetching);
+ viewer.addEventListener('parsing', handleParsing);
+ viewer.addEventListener('rebuilding', handleRebuilding);
+ viewer.addEventListener('titleChanged', handleTitleChanged);
+ viewer.settings.addEventListener('change:axes', handlePrefsChange);
+ viewer.settings.addEventListener('change:autoRotation', handlePrefsChange);
+
+ return () => {
+ viewer.removeEventListener('fetching', handleFetching);
+ viewer.removeEventListener('parsing', handleParsing);
+ viewer.removeEventListener('rebuilding', handleRebuilding);
+ viewer.removeEventListener('titleChanged', handleTitleChanged);
+ viewer.settings.removeEventListener('change:axes', handlePrefsChange);
+ viewer.settings.removeEventListener('change:autoRotation', handlePrefsChange);
+ };
+ }, [viewer, updateLoadingStage, handlePrefsChange]);
+
+ useEffect(() => {
+ if (!viewer) {
+ return;
+ }
+
+ if (frozen) {
+ viewer.halt();
+ } else {
+ viewer.run();
+ }
+ }, [frozen, viewer]);
+
+ useEffect(() => () => {
+ if (viewer) {
+ if (window.miew === viewer) {
+ window.miew = null;
+ }
+ onChange({ viewer: null });
+ }
+ }, [viewer, onChange]);
+
+ return (
+
+
+ {children}
+
+ );
+}
+
+MiewViewport.defaultProps = {
+ frozen: false,
+};
diff --git a/packages/miew-app/src/components/viewport/MiewViewport.test.jsx b/packages/miew-app/src/components/viewport/MiewViewport.test.jsx
new file mode 100644
index 00000000..544c6965
--- /dev/null
+++ b/packages/miew-app/src/components/viewport/MiewViewport.test.jsx
@@ -0,0 +1,184 @@
+import React from 'react';
+import { render, screen, act } from '@testing-library/react';
+import MiewViewport from './MiewViewport.jsx';
+import { useMiew } from '../../contexts/MiewContext';
+
+let miewReactViewerProps;
+
+jest.mock('miew-react', () => {
+ const MockMiewReactViewer = (props) => {
+ miewReactViewerProps = props;
+ return
;
+ };
+
+ return MockMiewReactViewer;
+});
+
+function createMockMiew() {
+ const handlers = {};
+ const settingHandlers = {};
+
+ return {
+ logger: { level: 'info' },
+ addEventListener: jest.fn((name, callback) => {
+ handlers[name] = callback;
+ }),
+ removeEventListener: jest.fn((name, callback) => {
+ if (handlers[name] === callback) {
+ delete handlers[name];
+ }
+ }),
+ settings: {
+ addEventListener: jest.fn((name, callback) => {
+ settingHandlers[name] = callback;
+ }),
+ removeEventListener: jest.fn((name, callback) => {
+ if (settingHandlers[name] === callback) {
+ delete settingHandlers[name];
+ }
+ }),
+ },
+ run: jest.fn(),
+ halt: jest.fn(),
+ emit(name, event = {}) {
+ if (handlers[name]) {
+ handlers[name](event);
+ }
+ },
+ emitSetting(name, event = {}) {
+ if (settingHandlers[name]) {
+ settingHandlers[name](event);
+ }
+ },
+ };
+}
+
+const MiewConsumer = () => {
+ const miew = useMiew();
+ return
{miew ? 'miew-ready' : 'miew-missing'}
;
+};
+
+describe('
', () => {
+ beforeEach(() => {
+ miewReactViewerProps = undefined;
+ window.miew = null;
+ jest.clearAllMocks();
+ });
+
+ it('passes miew-react wrapper props and default options', () => {
+ const onChange = jest.fn();
+ const updateLoadingStage = jest.fn();
+
+ render();
+
+ expect(screen.getByTestId('mock-viewer')).toBeDefined();
+ expect(miewReactViewerProps.className).toBe('miew-container');
+ expect(miewReactViewerProps.options).toEqual({
+ load: '1crn',
+ settings: { axes: false, fps: false },
+ });
+ expect(typeof miewReactViewerProps.onInit).toBe('function');
+ expect(typeof miewReactViewerProps.onError).toBe('function');
+ });
+
+ it('initializes Miew instance, updates context and forwards loading events', () => {
+ const onChange = jest.fn();
+ const updateLoadingStage = jest.fn();
+ const mockMiew = createMockMiew();
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText('miew-missing')).toBeDefined();
+
+ act(() => {
+ miewReactViewerProps.onInit(mockMiew);
+ });
+
+ expect(window.miew).toBe(mockMiew);
+ expect(mockMiew.logger.level).toBe('debug');
+ expect(onChange).toHaveBeenCalledWith({ viewer: mockMiew });
+ expect(screen.getByText('miew-ready')).toBeDefined();
+
+ act(() => {
+ mockMiew.emit('fetching');
+ mockMiew.emit('parsing');
+ mockMiew.emit('rebuilding');
+ mockMiew.emit('titleChanged', { data: 'My Molecule' });
+ mockMiew.emitSetting('change:axes', { changed: 'axes' });
+ mockMiew.emitSetting('change:autoRotation', { changed: 'autoRotation' });
+ });
+
+ expect(updateLoadingStage).toHaveBeenCalledWith('Fetching...');
+ expect(updateLoadingStage).toHaveBeenCalledWith('Parsing...');
+ expect(updateLoadingStage).toHaveBeenCalledWith('Building geometry...');
+ expect(updateLoadingStage).toHaveBeenCalledWith('My Molecule');
+ expect(onChange).toHaveBeenCalledWith({ prefs: { changed: 'axes' } });
+ expect(onChange).toHaveBeenCalledWith({ prefs: { changed: 'autoRotation' } });
+ });
+
+ it('handles init errors from miew-react wrapper', () => {
+ const onChange = jest.fn();
+ const updateLoadingStage = jest.fn();
+
+ render();
+
+ act(() => {
+ miewReactViewerProps.onError(new Error('init failed'));
+ });
+
+ expect(updateLoadingStage).toHaveBeenCalledWith('Failed to initialize viewer');
+ });
+
+ it('halts and runs Miew instance according to frozen prop changes', () => {
+ const onChange = jest.fn();
+ const updateLoadingStage = jest.fn();
+ const mockMiew = createMockMiew();
+
+ const { rerender } = render(
+ ,
+ );
+
+ act(() => {
+ miewReactViewerProps.onInit(mockMiew);
+ });
+
+ expect(mockMiew.run).toHaveBeenCalledTimes(1);
+ expect(mockMiew.halt).toHaveBeenCalledTimes(0);
+
+ rerender();
+ expect(mockMiew.halt).toHaveBeenCalledTimes(1);
+
+ rerender();
+ expect(mockMiew.run).toHaveBeenCalledTimes(2);
+ });
+
+ it('cleans up global Miew instance and notifies consumers on unmount', () => {
+ const onChange = jest.fn();
+ const updateLoadingStage = jest.fn();
+ const mockMiew = createMockMiew();
+
+ const { unmount } = render(
+ ,
+ );
+
+ act(() => {
+ miewReactViewerProps.onInit(mockMiew);
+ });
+
+ onChange.mockClear();
+ unmount();
+
+ expect(window.miew).toBeNull();
+ expect(onChange).toHaveBeenCalledWith({ viewer: null });
+ expect(mockMiew.removeEventListener).toHaveBeenCalledWith('fetching', expect.any(Function));
+ expect(mockMiew.removeEventListener).toHaveBeenCalledWith('parsing', expect.any(Function));
+ expect(mockMiew.removeEventListener).toHaveBeenCalledWith('rebuilding', expect.any(Function));
+ expect(mockMiew.removeEventListener).toHaveBeenCalledWith('titleChanged', expect.any(Function));
+ expect(mockMiew.settings.removeEventListener).toHaveBeenCalledWith('change:axes', expect.any(Function));
+ expect(mockMiew.settings.removeEventListener).toHaveBeenCalledWith('change:autoRotation', expect.any(Function));
+ });
+});
diff --git a/packages/miew-app/src/containers/MiewViewerContainer.js b/packages/miew-app/src/containers/MiewViewportContainer.js
similarity index 69%
rename from packages/miew-app/src/containers/MiewViewerContainer.js
rename to packages/miew-app/src/containers/MiewViewportContainer.js
index aeb3e2d0..ee7cb6aa 100644
--- a/packages/miew-app/src/containers/MiewViewerContainer.js
+++ b/packages/miew-app/src/containers/MiewViewportContainer.js
@@ -1,17 +1,17 @@
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
-import MiewViewer from '../components/viewer/MiewViewer.jsx';
+import MiewViewport from '../components/viewport/MiewViewport.jsx';
import {
updateLoadingStage,
} from '../actions';
-const MiewViewerContainer = (props) => {
+const MiewViewportContainer = (props) => {
const dispatch = useDispatch();
const frozen = useSelector((state) => state.visiblePanels?.visibility);
return (
- dispatch(updateLoadingStage(title))}
@@ -19,4 +19,4 @@ const MiewViewerContainer = (props) => {
);
};
-export default MiewViewerContainer;
+export default MiewViewportContainer;
diff --git a/packages/miew-app/webpack.config.js b/packages/miew-app/webpack.config.js
index 1cdc65ff..92dca2ff 100644
--- a/packages/miew-app/webpack.config.js
+++ b/packages/miew-app/webpack.config.js
@@ -44,12 +44,6 @@ module.exports = {
performance: {
hints: false,
},
- resolve: {
- alias: {
- MiewModule: (prod) ? path.resolve(__dirname, 'node_modules/miew/dist/Miew.module.js') : path.resolve(__dirname, '../miew/build/dist/Miew.module.js'),
- MiewStyles: (prod) ? path.resolve(__dirname, 'node_modules/miew/dist/Miew.css') : path.resolve(__dirname, '../miew/build/dist/Miew.css'),
- },
- },
plugins: [
new HtmlWebpackPlugin({
template: './src/index.html',
diff --git a/yarn.lock b/yarn.lock
index eb30b162..55c82a60 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -9930,6 +9930,7 @@ __metadata:
jquery: ^3.7.1
jquery.terminal: ^2.45.2
miew: "workspace:^"
+ miew-react: "workspace:^"
mini-css-extract-plugin: ^2.10.2
npm-run-all: ^4.1.5
popper.js: ^1.16.1
@@ -9960,7 +9961,7 @@ __metadata:
languageName: unknown
linkType: soft
-"miew-react@workspace:packages/miew-react":
+"miew-react@workspace:^, miew-react@workspace:packages/miew-react":
version: 0.0.0-use.local
resolution: "miew-react@workspace:packages/miew-react"
dependencies: