From 867f141499ad138fecce0f83d6e9fc3c78d7f952 Mon Sep 17 00:00:00 2001 From: Pavel Smirnov Date: Tue, 21 Apr 2026 10:40:12 -0400 Subject: [PATCH] Use miew-react in miew-app viewer --- packages/miew-app/package.json | 1 + packages/miew-app/src/components/App.jsx | 6 +- .../displayPreference/DisplayPreference.jsx | 2 +- .../src/components/viewer/MiewViewer.jsx | 67 ------- .../src/components/viewport/MiewViewport.jsx | 98 ++++++++++ .../components/viewport/MiewViewport.test.jsx | 184 ++++++++++++++++++ ...rContainer.js => MiewViewportContainer.js} | 8 +- packages/miew-app/webpack.config.js | 6 - yarn.lock | 3 +- 9 files changed, 293 insertions(+), 82 deletions(-) delete mode 100644 packages/miew-app/src/components/viewer/MiewViewer.jsx create mode 100644 packages/miew-app/src/components/viewport/MiewViewport.jsx create mode 100644 packages/miew-app/src/components/viewport/MiewViewport.test.jsx rename packages/miew-app/src/containers/{MiewViewerContainer.js => MiewViewportContainer.js} (69%) diff --git a/packages/miew-app/package.json b/packages/miew-app/package.json index 910194de..ccdd3494 100644 --- a/packages/miew-app/package.json +++ b/packages/miew-app/package.json @@ -43,6 +43,7 @@ "jquery": "^3.7.1", "jquery.terminal": "^2.45.2", "miew": "workspace:^", + "miew-react": "workspace:^", "react": "^19.2.5", "react-bootstrap": "^2.10.10", "react-dom": "^19.2.5", diff --git a/packages/miew-app/src/components/App.jsx b/packages/miew-app/src/components/App.jsx index 44dabfde..c29ea17a 100644 --- a/packages/miew-app/src/components/App.jsx +++ b/packages/miew-app/src/components/App.jsx @@ -1,7 +1,7 @@ import React from 'react'; import Menu from '../containers/MenuContainer'; -import Viewer from '../containers/MiewViewerContainer'; +import MiewViewport from '../containers/MiewViewportContainer'; import './App.scss'; export default class App extends React.Component { @@ -20,9 +20,9 @@ export default class App extends React.Component { render() { return
- + - +
; } } diff --git a/packages/miew-app/src/components/menu/displayPreference/DisplayPreference.jsx b/packages/miew-app/src/components/menu/displayPreference/DisplayPreference.jsx index e9c5e932..ad77bf81 100644 --- a/packages/miew-app/src/components/menu/displayPreference/DisplayPreference.jsx +++ b/packages/miew-app/src/components/menu/displayPreference/DisplayPreference.jsx @@ -1,6 +1,6 @@ import React, { useEffect, useRef, useMemo } from 'react'; -import Miew from 'MiewModule'; // eslint-disable-line import/no-unresolved +import Miew from 'miew'; import './DisplayPreference.scss'; import Thumbnail from './thumbnail/Thumbnail.jsx'; diff --git a/packages/miew-app/src/components/viewer/MiewViewer.jsx b/packages/miew-app/src/components/viewer/MiewViewer.jsx deleted file mode 100644 index 71733341..00000000 --- a/packages/miew-app/src/components/viewer/MiewViewer.jsx +++ /dev/null @@ -1,67 +0,0 @@ -import React, { useEffect, useRef } from 'react'; - -import 'MiewStyles'; // eslint-disable-line import/no-unresolved - -import Miew from 'MiewModule'; // eslint-disable-line import/no-unresolved -import { MiewProvider } from '../../contexts/MiewContext'; - -let viewer = null; -export default function MiewViewer({ - frozen, onChange, updateLoadingStage, children, -}) { - const domElement = useRef(); - const _onChange = (prefs) => { - onChange({ prefs }); - }; - - function removeViewer() { - // viewer.settings.now.removeEventListener(_onChange); - viewer.term(); - viewer = null; - onChange({ viewer }); - } - - useEffect(() => { - viewer = window.miew = new Miew({ container: domElement.current, load: '1crn' }); - viewer.addEventListener('fetching', () => { - updateLoadingStage('Fetching...'); - }); - viewer.addEventListener('parsing', () => { - updateLoadingStage('Parsing…'); - }); - viewer.addEventListener('rebuilding', () => { - updateLoadingStage('Building geometry…'); - }); - viewer.addEventListener('titleChanged', (e) => { - updateLoadingStage(e.data); - }); - viewer.logger.level = 'debug'; - - viewer.settings.addEventListener('change:axes', _onChange); - viewer.settings.addEventListener('change:autoRotation', _onChange); - onChange({ viewer }); - if (viewer.init()) { - viewer.run(); - } - return removeViewer; - }, []); - - useEffect(() => { - if (frozen) { - viewer.halt(); - } else { - viewer.run(); - } - }, [frozen]); - - return ( - -
- {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: