molstar
Version:
A comprehensive macromolecular library.
340 lines (339 loc) • 16.8 kB
JavaScript
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
/**
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
*/
import { useState } from 'react';
import { createRoot } from 'react-dom/client';
import { BehaviorSubject, throttleTime } from 'rxjs';
import { JSONCifLigandGraph } from '../../extensions/json-cif/ligand-graph';
import { ParseJSONCifFileData } from '../../extensions/json-cif/transformers';
import { MolViewSpec } from '../../extensions/mvs/behavior';
import { StructureElement, StructureProperties } from '../../mol-model/structure';
import { PluginStateObject } from '../../mol-plugin-state/objects';
import { ModelFromTrajectory, StructureFromModel, TrajectoryFromMmCif } from '../../mol-plugin-state/transforms/model';
import { StructureRepresentation3D } from '../../mol-plugin-state/transforms/representation';
import { PluginUIContext } from '../../mol-plugin-ui/context';
import { useBehavior } from '../../mol-plugin-ui/hooks/use-behavior';
import { Plugin } from '../../mol-plugin-ui/plugin';
import '../../mol-plugin-ui/skin/light.scss';
import { DefaultPluginUISpec } from '../../mol-plugin-ui/spec';
import { PluginCommands } from '../../mol-plugin/commands';
import { PluginConfig } from '../../mol-plugin/config';
import { PluginSpec } from '../../mol-plugin/spec';
import { download } from '../../mol-util/download';
import { GeometryEdits, TopologyEdits } from './edits';
import { ExampleMol } from './example-data';
import './index.html';
import { jsonCifToMolfile } from './molfile';
import { SingleTaskQueue } from './utils';
import { molfileToJSONCif } from '../../extensions/json-cif/utils';
async function init(target, molfile = ExampleMol) {
const root = typeof target === 'string' ? document.getElementById(target) : target;
const plugin = await createViewer(root);
const model = new EditorModel(plugin);
createRoot(root).render(_jsx(AppUI, { model: model }));
loadMolfile(model, molfile);
return model;
}
window.initLigandEditorExample = init;
async function createViewer(root) {
const spec = DefaultPluginUISpec();
const plugin = new PluginUIContext({
...spec,
layout: {
initial: {
isExpanded: false,
showControls: false
}
},
components: {
remoteState: 'none',
},
behaviors: [
...spec.behaviors,
PluginSpec.Behavior(MolViewSpec)
],
config: [
[PluginConfig.Viewport.ShowAnimation, false],
[PluginConfig.Viewport.ShowSelectionMode, false],
[PluginConfig.Viewport.ShowExpand, false],
[PluginConfig.Viewport.ShowControls, false],
]
});
await plugin.init();
plugin.managers.interactivity.setProps({ granularity: 'element' });
plugin.selectionMode = true;
return plugin;
}
async function loadMolfile(model, molfile) {
const { plugin } = model;
await plugin.clear();
const file = await molfileToJSONCif(molfile);
const update = plugin.build();
const data = update.toRoot()
.apply(ParseJSONCifFileData, { data: file.jsoncif });
data
.apply(TrajectoryFromMmCif)
.apply(ModelFromTrajectory)
.apply(StructureFromModel, { type: { name: 'model', params: {} } })
.apply(StructureRepresentation3D, {
type: { name: 'ball-and-stick', params: {} },
colorTheme: {
name: 'element-symbol',
params: { carbonColor: { name: 'element-symbol', params: {} } }
}
});
await update.commit();
model.setDataSelector(data.selector);
}
class EditorModel {
get data() {
var _a, _b, _c, _d;
return (_d = (_c = (_b = (_a = this.dataSelector) === null || _a === void 0 ? void 0 : _a.cell) === null || _b === void 0 ? void 0 : _b.transform) === null || _c === void 0 ? void 0 : _c.params) === null || _d === void 0 ? void 0 : _d.data;
}
get history() {
return this.state.history.value;
}
createGraph() {
var _a;
return new JSONCifLigandGraph((_a = this.data) === null || _a === void 0 ? void 0 : _a.dataBlocks[0]);
}
setDataSelector(selector) {
this.dataSelector = selector;
this.updateMolFile();
}
updateMolFile() {
var _a;
if (!this.data)
return this.state.molfile.next('');
try {
const molfile = jsonCifToMolfile((_a = this.data) === null || _a === void 0 ? void 0 : _a.dataBlocks[0], {
comment: 'Generated by Mol* Ligand Editor'
});
this.state.molfile.next(molfile);
}
catch (e) {
console.error('Failed to convert to molfile');
console.error(e);
this.state.molfile.next(`Error: ${e}`);
}
}
async update(data, pushHistory = true, historyData) {
if (!this.data)
return;
const updated = {
...this.data,
dataBlocks: [data],
};
if (pushHistory) {
this.state.history.next([...this.history, historyData !== null && historyData !== void 0 ? historyData : this.data]);
}
const update = this.plugin.build();
update.to(this.dataSelector).update({ data: updated });
await update.commit();
this.updateMolFile();
}
getEditableStructures() {
var _a;
if (!((_a = this.dataSelector) === null || _a === void 0 ? void 0 : _a.isOk))
return new Set();
const structures = this.plugin.state.data.selectQ(q => {
var _a;
return q
.byRef((_a = this.dataSelector) === null || _a === void 0 ? void 0 : _a.ref)
.subtree()
.filter(c => PluginStateObject.Molecule.Structure.is(c.obj));
});
return new Set(structures.map(s => { var _a; return (_a = s.obj) === null || _a === void 0 ? void 0 : _a.data; }));
}
getSelectedAtomIds() {
if (!this.data)
return [];
const structures = this.getEditableStructures();
if (structures.size === 0)
return [];
const { selection } = this.plugin.managers.structure;
const ids = [];
selection.entries.forEach(e => {
if (!structures.has(e.selection.structure))
return;
StructureElement.Loci.forEachLocation(e.selection, (l) => {
ids.push(StructureProperties.atom.id(l));
});
});
return ids;
}
async editGraphTopology(fn, ...args) {
try {
const graph = this.createGraph();
const result = await fn(graph, ...args);
const data = graph.getData().block;
await this.update(data);
this.plugin.managers.interactivity.lociSelects.deselectAll();
return result;
}
catch (e) {
console.error('Failed to edit graph');
console.error(e);
this.notify(`${e}`, 5000);
}
}
notify(message, timeoutMs = 2500) {
PluginCommands.Toast.Show(this.plugin, { key: '<edit>', title: 'Edit', message, timeoutMs });
}
beginGeometryEdit(fn, initial, ...args) {
try {
this.geometryEditValues.next([initial, false]);
const graph = this.createGraph();
this.geometryEditInitialData = this.data;
this.currentGeometryEdit = fn(graph, ...args);
this.currentGeomeryEditSub = this.geometryEditValues
.pipe(throttleTime(1000 / 60, undefined, { leading: true, trailing: true }))
.subscribe(this.applyGeometryEdit);
}
catch (e) {
console.error('Failed to edit graph');
console.error(e);
this.notify(`${e}`, 5000);
}
}
setGeometryEditValue(param, finish = false) {
this.geometryEditValues.next([param, finish]);
}
constructor(plugin) {
this.plugin = plugin;
this.dataSelector = undefined;
this.state = {
element: new BehaviorSubject('C'),
history: new BehaviorSubject([]),
molfile: new BehaviorSubject(''),
};
this.undo = async () => {
if (!this.dataSelector)
return;
if (this.history.length === 0)
return;
const data = this.history[this.history.length - 1];
this.state.history.next(this.history.slice(0, this.history.length - 1));
const update = this.plugin.build();
update.to(this.dataSelector).update({ data });
await update.commit();
this.updateMolFile();
};
this.setElement = async () => {
const symbol = this.state.element.value.trim();
if (!symbol)
return this.notify('No element symbol provided');
const ids = this.getSelectedAtomIds();
if (!ids.length)
return this.notify('No atoms selected');
await this.editGraphTopology(TopologyEdits.setElement, ids, symbol);
};
this.addElement = async () => {
const symbol = this.state.element.value.trim();
if (!symbol)
return this.notify('No element symbol provided');
const ids = this.getSelectedAtomIds();
if (ids.length !== 1)
return this.notify('Select a single atom to add a new atom to');
await this.editGraphTopology(TopologyEdits.addElement, ids[0], symbol);
};
this.removeAtoms = async () => {
const ids = this.getSelectedAtomIds();
if (!ids.length)
return this.notify('No atoms selected');
await this.editGraphTopology(TopologyEdits.removeAtoms, ids);
};
this.removeBonds = async () => {
const ids = this.getSelectedAtomIds();
if (!ids.length)
return this.notify('No atoms selected');
await this.editGraphTopology(TopologyEdits.removeBonds, ids);
};
this.updateBonds = async (props) => {
const ids = this.getSelectedAtomIds();
if (!ids.length)
return this.notify('No atoms selected');
await this.editGraphTopology(TopologyEdits.updateBonds, ids, props);
};
this.attachRgroup = async (name) => {
const ids = this.getSelectedAtomIds();
if (ids.length !== 1)
return this.notify('Select a single hydrogen atom to attach an R-group to');
await this.editGraphTopology(TopologyEdits.attachRgroup, ids[0], name);
};
this.geometryEditInitialData = undefined;
this.geometryEditValues = new BehaviorSubject([0, false]);
this.currentGeometryEdit = undefined;
this.currentGeomeryEditSub = undefined;
this.geometryEditQueue = new SingleTaskQueue();
this.applyGeometryEdit = ([param, finish]) => {
var _a;
if (!this.currentGeometryEdit)
return;
const graph = this.currentGeometryEdit(param);
const data = graph.getData().block;
const initialData = this.geometryEditInitialData;
if (finish) {
this.currentGeometryEdit = undefined;
(_a = this.currentGeomeryEditSub) === null || _a === void 0 ? void 0 : _a.unsubscribe();
this.currentGeomeryEditSub = undefined;
this.geometryEditInitialData = undefined;
}
this.geometryEditQueue.run(() => this.update(data, finish, initialData));
};
this.twist = () => {
this.beginGeometryEdit(GeometryEdits.twist, 0, this.getSelectedAtomIds());
};
this.stretch = () => {
this.beginGeometryEdit(GeometryEdits.stretch, 0, this.getSelectedAtomIds());
};
}
}
function AppUI({ model }) {
return _jsxs("div", { style: { display: 'flex', flexDirection: 'row', height: '100%', width: '100%' }, children: [_jsx("div", { style: { flexGrow: 1, display: 'block', position: 'relative' }, children: _jsx(Plugin, { plugin: model.plugin }) }), _jsx("div", { style: { flexShrink: 0, minWidth: 500, width: 400, display: 'flex', flexDirection: 'column', gap: '5px' }, children: _jsx(ControlsUI, { model: model }) })] });
}
function ControlsUI({ model }) {
return _jsxs("div", { style: { display: 'flex', flexDirection: 'column', gap: '5px', padding: 8, overflow: 'hidden', overflowY: 'auto' }, className: 'editor-controls', children: [_jsx("div", { children: _jsx(UndoButton, { model: model }) }), _jsx("b", { children: "Atoms" }), _jsxs("div", { style: { display: 'flex', gap: '5px' }, children: [_jsx("button", { onClick: model.removeAtoms, children: "Remove" }), _jsxs("div", { children: [_jsx(ElementEditUI, { model: model }), _jsx("button", { onClick: model.setElement, style: { borderLeft: 'none' }, children: "Set Element" }), _jsx("button", { onClick: model.addElement, style: { borderLeft: 'none' }, children: "Add Element" })] })] }), _jsx("b", { children: "Bonds" }), _jsxs("div", { style: { display: 'flex', gap: '5px' }, children: [_jsx("button", { onClick: model.removeBonds, children: "Remove" }), _jsx("button", { onClick: () => model.updateBonds({ value_order: 'sing', type_id: 'covale' }), children: "-" }), _jsx("button", { onClick: () => model.updateBonds({ value_order: 'doub', type_id: 'covale' }), children: "=" }), _jsx("button", { onClick: () => model.updateBonds({ value_order: 'trip', type_id: 'covale' }), children: "\u2261" })] }), _jsx("b", { children: "R-groups" }), _jsx("div", { style: { display: 'flex', gap: '5px' }, children: _jsxs("button", { onClick: () => model.attachRgroup('CH3'), children: ["-CH", _jsx("sub", { children: "3" })] }) }), _jsx("b", { children: "Geometry" }), _jsx(TwistUI, { model: model }), _jsx(StretchUI, { model: model }), _jsx("b", { children: "Molfile" }), _jsx(MolFileUI, { model: model })] });
}
function MolFileUI({ model }) {
const molfile = useBehavior(model.state.molfile);
return _jsxs(_Fragment, { children: [_jsx("textarea", { value: molfile, readOnly: true, style: { width: '100%', height: 200, fontFamily: 'monospace', fontSize: '10px' } }), _jsxs("div", { style: { display: 'flex', gap: '5px' }, children: [_jsx("button", { onClick: () => navigator.clipboard.writeText(molfile), children: "Copy" }), _jsx("button", { onClick: () => download(new Blob([molfile], { type: 'text/plain' }), `edited-molecule-${Date.now()}.mol`), children: "Save" })] })] });
}
function UndoButton({ model }) {
const history = useBehavior(model.state.history);
return _jsxs("button", { onClick: model.undo, disabled: history.length === 0, children: ["Undo [", history.length, "]"] });
}
function ElementEditUI({ model }) {
const element = useBehavior(model.state.element);
return _jsx("input", { type: "text", value: element, style: { width: 50 }, onChange: e => model.state.element.next(e.target.value) });
}
const GeometryLabelWidth = 60;
function TwistUI({ model }) {
const [value, setValue] = useState(0);
return _jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: 8 }, children: [_jsx("i", { style: { width: GeometryLabelWidth }, children: "Twist" }), " ", _jsx("input", { type: 'range', min: -60, max: 60, step: 1, value: value, onMouseDown: model.twist, onMouseUp: (e) => {
requestAnimationFrame(() => {
model.setGeometryEditValue(Math.PI * value / 60, true);
setValue(0);
});
}, onChange: e => {
const value = +e.target.value;
setValue(value);
model.setGeometryEditValue(Math.PI * value / 60);
} })] });
}
function StretchUI({ model }) {
const [value, setValue] = useState(0);
return _jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: 8 }, children: [_jsx("i", { style: { width: GeometryLabelWidth }, children: "Stretch" }), " ", _jsx("input", { type: 'range', min: -60, max: 60, step: 1, value: value, onMouseDown: model.stretch, onMouseUp: (e) => {
requestAnimationFrame(() => {
model.setGeometryEditValue(0.5 * value / 60, true);
setValue(0);
});
}, onChange: e => {
const value = +e.target.value;
setValue(value);
model.setGeometryEditValue(0.5 * value / 60);
} })] });
}