molstar
Version:
A comprehensive macromolecular library.
342 lines (341 loc) • 18 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const jsx_runtime_1 = require("react/jsx-runtime");
/**
* Copyright (c) 2025 mol* contributors, licensed under MIT, See LICENSE file for more info.
*
* @author David Sehnal <david.sehnal@gmail.com>
*/
const react_1 = require("react");
const client_1 = require("react-dom/client");
const rxjs_1 = require("rxjs");
const ligand_graph_1 = require("../../extensions/json-cif/ligand-graph");
const transformers_1 = require("../../extensions/json-cif/transformers");
const behavior_1 = require("../../extensions/mvs/behavior");
const structure_1 = require("../../mol-model/structure");
const objects_1 = require("../../mol-plugin-state/objects");
const model_1 = require("../../mol-plugin-state/transforms/model");
const representation_1 = require("../../mol-plugin-state/transforms/representation");
const context_1 = require("../../mol-plugin-ui/context");
const use_behavior_1 = require("../../mol-plugin-ui/hooks/use-behavior");
const plugin_1 = require("../../mol-plugin-ui/plugin");
require("../../mol-plugin-ui/skin/light.scss");
const spec_1 = require("../../mol-plugin-ui/spec");
const commands_1 = require("../../mol-plugin/commands");
const config_1 = require("../../mol-plugin/config");
const spec_2 = require("../../mol-plugin/spec");
const download_1 = require("../../mol-util/download");
const edits_1 = require("./edits");
const example_data_1 = require("./example-data");
require("./index.html");
const molfile_1 = require("./molfile");
const utils_1 = require("./utils");
const utils_2 = require("../../extensions/json-cif/utils");
async function init(target, molfile = example_data_1.ExampleMol) {
const root = typeof target === 'string' ? document.getElementById(target) : target;
const plugin = await createViewer(root);
const model = new EditorModel(plugin);
(0, client_1.createRoot)(root).render((0, jsx_runtime_1.jsx)(AppUI, { model: model }));
loadMolfile(model, molfile);
return model;
}
window.initLigandEditorExample = init;
async function createViewer(root) {
const spec = (0, spec_1.DefaultPluginUISpec)();
const plugin = new context_1.PluginUIContext({
...spec,
layout: {
initial: {
isExpanded: false,
showControls: false
}
},
components: {
remoteState: 'none',
},
behaviors: [
...spec.behaviors,
spec_2.PluginSpec.Behavior(behavior_1.MolViewSpec)
],
config: [
[config_1.PluginConfig.Viewport.ShowAnimation, false],
[config_1.PluginConfig.Viewport.ShowSelectionMode, false],
[config_1.PluginConfig.Viewport.ShowExpand, false],
[config_1.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 (0, utils_2.molfileToJSONCif)(molfile);
const update = plugin.build();
const data = update.toRoot()
.apply(transformers_1.ParseJSONCifFileData, { data: file.jsoncif });
data
.apply(model_1.TrajectoryFromMmCif)
.apply(model_1.ModelFromTrajectory)
.apply(model_1.StructureFromModel, { type: { name: 'model', params: {} } })
.apply(representation_1.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 ligand_graph_1.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 = (0, molfile_1.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 => objects_1.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;
structure_1.StructureElement.Loci.forEachLocation(e.selection, (l) => {
ids.push(structure_1.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) {
commands_1.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((0, rxjs_1.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 rxjs_1.BehaviorSubject('C'),
history: new rxjs_1.BehaviorSubject([]),
molfile: new rxjs_1.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(edits_1.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(edits_1.TopologyEdits.addElement, ids[0], symbol);
};
this.removeAtoms = async () => {
const ids = this.getSelectedAtomIds();
if (!ids.length)
return this.notify('No atoms selected');
await this.editGraphTopology(edits_1.TopologyEdits.removeAtoms, ids);
};
this.removeBonds = async () => {
const ids = this.getSelectedAtomIds();
if (!ids.length)
return this.notify('No atoms selected');
await this.editGraphTopology(edits_1.TopologyEdits.removeBonds, ids);
};
this.updateBonds = async (props) => {
const ids = this.getSelectedAtomIds();
if (!ids.length)
return this.notify('No atoms selected');
await this.editGraphTopology(edits_1.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(edits_1.TopologyEdits.attachRgroup, ids[0], name);
};
this.geometryEditInitialData = undefined;
this.geometryEditValues = new rxjs_1.BehaviorSubject([0, false]);
this.currentGeometryEdit = undefined;
this.currentGeomeryEditSub = undefined;
this.geometryEditQueue = new utils_1.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(edits_1.GeometryEdits.twist, 0, this.getSelectedAtomIds());
};
this.stretch = () => {
this.beginGeometryEdit(edits_1.GeometryEdits.stretch, 0, this.getSelectedAtomIds());
};
}
}
function AppUI({ model }) {
return (0, jsx_runtime_1.jsxs)("div", { style: { display: 'flex', flexDirection: 'row', height: '100%', width: '100%' }, children: [(0, jsx_runtime_1.jsx)("div", { style: { flexGrow: 1, display: 'block', position: 'relative' }, children: (0, jsx_runtime_1.jsx)(plugin_1.Plugin, { plugin: model.plugin }) }), (0, jsx_runtime_1.jsx)("div", { style: { flexShrink: 0, minWidth: 500, width: 400, display: 'flex', flexDirection: 'column', gap: '5px' }, children: (0, jsx_runtime_1.jsx)(ControlsUI, { model: model }) })] });
}
function ControlsUI({ model }) {
return (0, jsx_runtime_1.jsxs)("div", { style: { display: 'flex', flexDirection: 'column', gap: '5px', padding: 8, overflow: 'hidden', overflowY: 'auto' }, className: 'editor-controls', children: [(0, jsx_runtime_1.jsx)("div", { children: (0, jsx_runtime_1.jsx)(UndoButton, { model: model }) }), (0, jsx_runtime_1.jsx)("b", { children: "Atoms" }), (0, jsx_runtime_1.jsxs)("div", { style: { display: 'flex', gap: '5px' }, children: [(0, jsx_runtime_1.jsx)("button", { onClick: model.removeAtoms, children: "Remove" }), (0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)(ElementEditUI, { model: model }), (0, jsx_runtime_1.jsx)("button", { onClick: model.setElement, style: { borderLeft: 'none' }, children: "Set Element" }), (0, jsx_runtime_1.jsx)("button", { onClick: model.addElement, style: { borderLeft: 'none' }, children: "Add Element" })] })] }), (0, jsx_runtime_1.jsx)("b", { children: "Bonds" }), (0, jsx_runtime_1.jsxs)("div", { style: { display: 'flex', gap: '5px' }, children: [(0, jsx_runtime_1.jsx)("button", { onClick: model.removeBonds, children: "Remove" }), (0, jsx_runtime_1.jsx)("button", { onClick: () => model.updateBonds({ value_order: 'sing', type_id: 'covale' }), children: "-" }), (0, jsx_runtime_1.jsx)("button", { onClick: () => model.updateBonds({ value_order: 'doub', type_id: 'covale' }), children: "=" }), (0, jsx_runtime_1.jsx)("button", { onClick: () => model.updateBonds({ value_order: 'trip', type_id: 'covale' }), children: "\u2261" })] }), (0, jsx_runtime_1.jsx)("b", { children: "R-groups" }), (0, jsx_runtime_1.jsx)("div", { style: { display: 'flex', gap: '5px' }, children: (0, jsx_runtime_1.jsxs)("button", { onClick: () => model.attachRgroup('CH3'), children: ["-CH", (0, jsx_runtime_1.jsx)("sub", { children: "3" })] }) }), (0, jsx_runtime_1.jsx)("b", { children: "Geometry" }), (0, jsx_runtime_1.jsx)(TwistUI, { model: model }), (0, jsx_runtime_1.jsx)(StretchUI, { model: model }), (0, jsx_runtime_1.jsx)("b", { children: "Molfile" }), (0, jsx_runtime_1.jsx)(MolFileUI, { model: model })] });
}
function MolFileUI({ model }) {
const molfile = (0, use_behavior_1.useBehavior)(model.state.molfile);
return (0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("textarea", { value: molfile, readOnly: true, style: { width: '100%', height: 200, fontFamily: 'monospace', fontSize: '10px' } }), (0, jsx_runtime_1.jsxs)("div", { style: { display: 'flex', gap: '5px' }, children: [(0, jsx_runtime_1.jsx)("button", { onClick: () => navigator.clipboard.writeText(molfile), children: "Copy" }), (0, jsx_runtime_1.jsx)("button", { onClick: () => (0, download_1.download)(new Blob([molfile], { type: 'text/plain' }), `edited-molecule-${Date.now()}.mol`), children: "Save" })] })] });
}
function UndoButton({ model }) {
const history = (0, use_behavior_1.useBehavior)(model.state.history);
return (0, jsx_runtime_1.jsxs)("button", { onClick: model.undo, disabled: history.length === 0, children: ["Undo [", history.length, "]"] });
}
function ElementEditUI({ model }) {
const element = (0, use_behavior_1.useBehavior)(model.state.element);
return (0, jsx_runtime_1.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] = (0, react_1.useState)(0);
return (0, jsx_runtime_1.jsxs)("div", { style: { display: 'flex', alignItems: 'center', gap: 8 }, children: [(0, jsx_runtime_1.jsx)("i", { style: { width: GeometryLabelWidth }, children: "Twist" }), " ", (0, jsx_runtime_1.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] = (0, react_1.useState)(0);
return (0, jsx_runtime_1.jsxs)("div", { style: { display: 'flex', alignItems: 'center', gap: 8 }, children: [(0, jsx_runtime_1.jsx)("i", { style: { width: GeometryLabelWidth }, children: "Stretch" }), " ", (0, jsx_runtime_1.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);
} })] });
}